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 took some time away from programming to later came back and struggle with package updates, outdated dependencies, or broken code? This happens because if your project has too many libs & packages, you might want to consider reducing the number of external imports your project has.

We experienced this recently and saw it as an opportunity to write a small helper util to replace a popular package.
Today I'll be refactoring, and improving, a popular package called clsx (also classnames and others).

Planning

With clsx you can pass a bunch of strings, objects, arrays and it'll always resolve to a string of classes to be used in your elements. If you're using something like Tailwind, where everything is done through classes, you probably rely a lot on that function.

However, my colleagues and me rarely called it with objects.
So, instead of something like 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'd rather have 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

Actually, with the addition of the || operator, the end API turned out to be better for our needs.

The implementation

It is a good practice to always start by modeling the types:

type Cx = (...a: Array<undefined | null | string | boolean>) => string
Enter fullscreen mode Exit fullscreen mode

So basically we need to accept strings, nullish values and booleans and then strip them out (including true so we can take advantage of the || operator)

This project heavily uses lodash so we've used it to compose the function:

import { compose, join, filter, isBoolean, isNil, flatten } from 'lodash/fp'

const cx: Cx = (...args) => 
  compose(join(' '), filter(isBoolean), filter(isNil), flatten)(args)
Enter fullscreen mode Exit fullscreen mode

And of course, as I said in the start of this post, if you don't like to be adding packages for everything you'll want the vanilla version:

const cx: Cx = (...args) =>
  args
    .flat()
    .filter(x => 
      x !== null && x !== undefined && typeof x !== 'boolean'
    ).join(' ')
Enter fullscreen mode Exit fullscreen mode

Conclusion

Think twice before adding yet another package. Sometimes everything you need is couple lines of code - which is less than what goes to your package-lock.json at the end of the day.

Top comments (5)

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
 
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 😜