Something I don’t like from shadcn/ui

Pablo Haller
6 min readMay 26, 2024

--

Don’t get me wrong, I love shadcn/ui. It’s an amazing collection of components, and I find it so cool for quickly building beautiful UIs with an excellent UX. It also inspires me when creating my own components, considering how shadcn/ui’s are implemented.

Being that said…

Have you seen this function?

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

Does it seem strange to you? No? Not even the lack of semicolons?

Well, it caught my attention the first time I saw it; you can find it here on shadcn/ui’s manual installation page, and I’ve seen a lot of people using this utility in different projects, that use two amazing libraries to handle dynamic CSS classes: tailwind-merge and clsx.

I knew about tailwind-merge from a previous project, and I it helped us a lot to merge (heh) Tailwind CSS strings/classes into one, especially, for conditional classes that should be applied to a component when it is, exempli gratia, selected or disabled by props or state.

I also knew about clsx, which was a little bit newer to me. I discovered its existence while doing some code reviews. I’m okay with it, I love it too, as it is a very useful tool to accomplish exactly the same (conditionally merging classes into one), just an alternative, I thought.

But, but, but… looking at them, both together for the first time in one function was mind-blowing.

I started to do a little research… just because I didn’t like it at all, not gonna lie. I learned a few things along the way while diving into different docs and GitHub issues/discussions.

First of all, tailwind-merge has become my favorite tool for handling classes, just because their “efficiently merge Tailwind CSS classes in JS without style conflicts” phrase hooked me in. “Efficiently” always make me buy in.

My interpretation is that its efficiency comes from its conflict-solving features. There might be many other features (as it also caches results, did you know that?), but we’re not here for that. Let me explain a little bit better what “conflict solving” mean.

Long story short (straight out of tailwind-merge docs), you will face conflicts when you encounter a situation like this:

const Input = ({ className, ...props }) => {
const customClassNames = `border rounded px-2 py-1 ${className || ''}`
return <input {...props} className={customClassNames} />;
}

const CustomInput = (props) => <Input {...props} className="p-3" />;

There, we are trying to apply p-3 in our CustomInput component using Input. However, due to how CSS cascading works, p-3 is ignored, and only px-2 and px-1 are applied when rendered. This forces us to manually remove the conflicting classes. Tailwind-merge handles this automatically for us.

If you want to know more interesting conflicting cases, check out this video that you can also find in the official tailwind-merge docs.

On the other hand, clsx is a utility to construct className strings conditionally. It’s pretty straightforward:

import clsx from 'clsx';

// Do this:
clsx('foo', true && 'bar', 'baz');
// => Get this: 'foo bar baz'

// Do this:
clsx({ foo: true, bar: false, baz: isTrue() });
// => Get this: 'foo baz'

It doesn’t solve any conflicts, but it still works if you know what to do. If you are careful enough, and your use cases are mostly getting conditional classes… do you really need tailwind-merge to really not handle any conflicts? Because, let me tell you, if your are a bundle-size-phobic, what library would you choose between a 700kB and a 6.6kB library?

Of course, we’re not perfect, and we might want to sacrifice some hundreds kilobytes just if anything goes wrong to face some other complex scenarios in the future, after all, they do exactly the same, right?

But why using them at the same time?

WHY?

And do you want to know something crazier?

Dany Castillo (love ya’ buddy), the clever dev behind tailwind-merge, made a copy of clsx inside tailwind-merge, and its named twJoin! And those are his words, not mine! And I’ll leave a proof at the end of this post.

twJoin does exactly the same as clsx! tailwind-merge calls join to just “merging” together all the classes, and merge as “joining” them with conflict solving features. In other words: making one string out of many, and solving (merge) or not (join) any conflicts that might come.

So, let’s make a little draw to understand how crazy this sounds so far:

tailwind-merge might have everything we need, but we still use them together when using cn utility. What does clsx cover that tailwind-merge does’t? What is that big question mark over there? What is so important and needed that we need to use both libraries that, so far, seem to do exactly the same at the same time?

To me, it sounds as crazy as…

// Do this ->
parseInt(Number("10")); // -> 10
// Or do this ->
parseInt(Number(null)); // -> 0
// Or do this ->
Number(parseInt(null)); // -> NaN

And as well as using parseInt and Number have “slight” differences, is this too?

Well, I started to dig a little bit.

On August 26, 2022, before shadcn/ui release, GitHub user MrEfrem publicly asked to implement a new feature in tailwind-merge: enabling the use object syntax arguments for conditionally applying rules, just as clsx does.

Dany took his time to point out:

  • Why he thinks it’s not necessary, providing a detailed explanation on why short circuits are easier to read than object syntax, looking first at the conditions that are applied rather than the style its going to be applied, avoiding going back and forth in the code, making every new line start with a condition instead of a “non-semantic-nor-self-explanatory-classname of unknown length”.
  • And that twJoin is a copy of clsx without object support syntax to remove runtime overhead…
  • But, there is a workaround to do so… even if clsx is a subset of tailwind-merge without object syntax support, called twJoin, that even works a little bit faster according to the documentation.

You can look at these statements over here from Dany itself.

To me (and this is an invention) here’s from where that function came from.

const HAS_ERRORS = true;

// Is this:
cn({ "show-me-an-error-style": HAS_ERRORS });

// Against this:
twMerge(HAS_ERRORS && "show-me-an-error-style");

At the end of the day, that cn function is just there because of some syntax preference… but, is it worth it?

Adding two libraries, that do pretty much the same… remembering, again, that one contains the other, and does exactly the same, without an object syntax support?

If you ask me, is not worth it, even if we’re only saving 7kB and runtime overhead that Dany intended to avoid, and we could be kinda “ruining” (somehow?) the whole efficiency statement by adding it again (it doesn’t… it doesn’t?).

Remember the concepts of joining and merging:

The function first does join-clsx, then twJoin (join-clsx without object support), and finally merges (conflict solving). Why that second join through twJoin? Well, because internally twMerge also uses twJoin (check here).

Now I ask myself… is tailwind-merge, as heavy as it is, worth it for the simple cases I’m handling, when I only need to be more careful about what I’m doing? If I think of it, is not, but I prefer to use it as a safety net, just in case for the future me not paying enough attention while coding or reviewing a PR.

At the end of the day, its all a matter of preference.

What do you think about all of this? Am I missing something important? Please, let me know if you think so.

--

--