DEV Community

Cover image for Replace clsx, classnames or classcat with your own little helper
Gustavo Guichard
Gustavo Guichard

Posted on • Updated on

Replace clsx, classnames or classcat with your own little helper

Have you ever taken a break from programming, only to return and wrestle with package updates, outdated dependencies, or broken code? This issue often arises if your project heavily relies on a multitude of libraries and packages. In such instances, you might want to consider reducing the number of external imports your project utilizes.

We encountered this issue recently and perceived it as an opportunity to write a small helper utility that could replace a popular package.
Today, I'll be replacing a widely-used package called clsx (or classnames, etc) with a tiny function.

Planning

With clsx, you can pass a mix of strings, objects, arrays, and it consistently resolves to a string of classes to be used in your elements. If you're using a framework like Tailwind, where everything is accomplished through classes, you likely depend heavily on this function.

However, my colleagues and I rarely used it with objects.

Instead of this:

clsx('base', undefined, ['more', 'classes'], {
  'bg-red': hasError,
  'pointer-events-none': !isEnabled,
  'font-semibold': isTitle,
  'font-normal': !isTitle,
})

// Result: "base more classes bg-red font-normal"
Enter fullscreen mode Exit fullscreen mode

We prefer an API like:

cx('base', undefined, ['more', 'classes'],
  hasError && 'bg-red',
  isEnabled || 'pointer-events-none',
  isTitle ? 'font-semibold' : 'font-normal'
)

// Result: "base more classes bg-red font-normal"
Enter fullscreen mode Exit fullscreen mode

In fact, with the addition of the || operator, the final API proved to be even better suited for our needs.

The implementation

The final version of our function, which is just a few lines of code, accepts an array of unknown values, filters for strings, joins them with a space, and trims any leading or trailing whitespace:

function cx(...args: unknown[]) {
  return args
    .flat()
    .filter(x => typeof x === 'string')
    .join(' ')
    .trim()
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Think twice before adding yet another package. Sometimes, all you need are a few lines of code - which might be fewer than the additions made to your package-lock.json at the end of the day. Always consider whether you can achieve the same functionality with a simpler, custom solution.

Top comments (18)

Collapse
 
sarajohn130 profile image
sara john • Edited

When you say "We prefer an API like:", and your helper does hasError && 'bg-red', doesn't clsx also support this style? On the npm page npmjs.com/package/clsx, I see an example that uses your style, namely true && 'bar'

Collapse
 
gugaguichard profile image
Gustavo Guichard

it means we don't care about the object-like API, especially because with a tech like Tailwind the keys of the object get too long and it doesn't feel right.
But also we like to be able to do: isEnabled || 'pointer-events-none' and the || last time I checked was not supported by those libs

Collapse
 
sarajohn130 profile image
sara john

Doing !isEnabled && 'pointer-events-none' or isDisabled && 'pointer-events-none' is equivalent so that's kind of a nitpick.

Thread Thread
 
gugaguichard profile image
Gustavo Guichard

Yes, I’m not saying it is not possible to do it otherwise. I’m just saying that I’d rather build my own simple API that works the way I want so it can have these nuances.

I’m trying to understand if you are advocating for using clsx or what. I’m not against it at all, my whole point is that sometimes you can replace a library with one line of code. Ignoring that can lead to the isEven package situation.

Thread Thread
 
sarajohn130 profile image
sara john • Edited

Fair point. Might as well replace a library if it can be replicated with just your 5 lines of code. I'm not for or against clsx. I originally thought you were advocating for better syntax if you wrote your own clsx, but looking into it the difference is not significant between the 2. But now I believe syntax is not the main point of this post but instead it's to avoid the isEven scenario?

Also in your previous comment when you said "it means we don't care about the object-like API, especially because with a tech like Tailwind the keys of the object get too long and it doesn't feel right.", could you elaborate on that? I did not understand.

Thread Thread
 
gugaguichard profile image
Gustavo Guichard

Sure, I mean the following API is weird:

clsx('bg-white rounded shadow', {
  'px-4 py-3 bg-slate-50 shadow-lg': isHovering,
})
Enter fullscreen mode Exit fullscreen mode

Got it?

Collapse
 
sarajohn130 profile image
sara john • Edited

In your example

clsx('base', undefined, ['more', 'classes'], {
  'bg-red': hasError,
  'pointer-events-none': !isEnabled,
  'font-semibold': isTitle,
  'font-normal': !isTitle,
})
Enter fullscreen mode Exit fullscreen mode

what is the point of undefined and 'base?' Shouldn't 'base' be in ['more', 'classes']?

The proper example seems to look like this:

clsx(['base', 'more', 'classes'], {
  'bg-red': hasError,
  'pointer-events-none': !isEnabled,
  'font-semibold': isTitle,
  'font-normal': !isTitle,
})
Enter fullscreen mode Exit fullscreen mode
Collapse
 
gugaguichard profile image
Gustavo Guichard

maybe I was not as clear but I'm showing that both clsx or the solution here will concat, flatten, and filter classes.

Collapse
 
dbenfouzari profile image
Donovan BENFOUZARI

It would better be replaced with something like this, that handles objects, arrays, and everything you would pass to to function

type StringArg = string | number | null | undefined;
type ObjArg = Record<string, any>;
type ArrArg = Array<StringArg | ObjArg | ArrArg>;
export type ClsxArgs = Array<StringArg | ObjArg | ArrArg>;

export function clsx(...args: ClsxArgs) {
  return args
    .reduce<Array<string | number>>((previousValue, currentValue) => {
      if (!currentValue) return previousValue;

      if (Array.isArray(currentValue)) {
        previousValue.concat(clsx(...currentValue));
        return previousValue;
      }

      if (typeof currentValue === "object") {
        Object.entries(currentValue).forEach(([k, v]) => {
          if (v) previousValue.push(k);
        });
        return previousValue;
      }

      previousValue.push(currentValue);
      return previousValue;
    }, [])
    .join(" ");
}

Enter fullscreen mode Exit fullscreen mode
Collapse
 
gugaguichard profile image
Gustavo Guichard

Actually nowadays I'm leaning towards this:

function cn(...args: Array<undefined | null | string | boolean>): string {
  return args
    .flat()
    .filter(x => typeof x === "string")
    .join(" ");
}
Enter fullscreen mode Exit fullscreen mode

Simplicity - which is the goal of this post - and I don't think it should have several APIs. I also don't like the object API as we usually have a bunch of classes - especially when using tailwind - as keys of object.

Collapse
 
dbenfouzari profile image
Donovan BENFOUZARI

Yes you are right, I understand. It might be better sometimes to not use objects as you wrote it. Thanks :)

Collapse
 
sarajohn130 profile image
sara john

When you said "We encountered this issue recently and perceived it as an opportunity to write a small helper utility that could replace a popular package.", could you elaborate on it? What package was it and how did you solve it?

Collapse
 
gugaguichard profile image
Gustavo Guichard

it had to do with the previous package: coming back from vacations and there's a lot of package updates to do... but I'm not attached to that sentence, do you want to suggest something else?

Collapse
 
sarajohn130 profile image
sara john

Nope, just curious how updating packages went for you. I read from someone online that for the past 2 years their team started using a serverless framework they made that allowed them to ship very fast. And they don't have to care about dependencies anymore.

Collapse
 
joenano profile image
joenano

Doesn't the inclusion of lodash defeat the purpose here?

Collapse
 
gugaguichard profile image
Gustavo Guichard

By the time I wrote this post I ise to have lodash in pretty much every project. That is not the case anymore so I’ve been going with the vanilla approach.
Actually nowadays I’m just doing:

function cx(…args: unknown[]) {
  return args
    .flat()
    .filter(x => typeof x === 'string')
    .join(' ')
    .trim()
}
Enter fullscreen mode Exit fullscreen mode

I guess I should update the post 😜

Collapse
 
sarajohn130 profile image
sara john

You should update it now

Thread Thread
 
gugaguichard profile image
Gustavo Guichard

Updated it, thx