What's the problem?
The className
property in React accepts only a single string value. If you need multiple classes and conditional classes (add class x if y is true) you need to create an array, add class names depending on the condition and join the names together.
const className = ["a", isAdmin ? "b" : ""].join(" ");
return <button className={className}>...</button>;
It works but is not very handy. There are third party utility functions like classnames that make this easier. But we can do it better!
What we'll build
We will build our own utility function that works with chainable class names that can be conditionally applied. That is how it will look like:
<p className={$.paragraph.center.large(isLarge)}>nice!</p>
This will add the classes paragraph
, center
and - if isLarge
is truthy - the class large
. The utility function will work with CSS Modules, so the actual class names are hashed.
What are CSS Modules?
If you are familiar with CSS Modules you can skip this section.
CSS Modules lets you write CSS that is scoped locally to your component. It makes your class names unique by adding a hash value to them.
Further it generates a mapping between your class name and the final class name that includes the hash. When you import the CSS file in your JSX file you will get that mapping as a JS object. You can then use that in your component to get the actual class name.
import css from "./styles.module.css";
function MyButton() {
// css.Button returns the unique class name "Button__a3mdu"
return <button className={css.Button}>...</button>;
}
Existing solutions
Working with multiple classes and conditional classes in React is not pretty nice out of the box as we saw at the beginning of this article.
Utility functions are useful to make this task a bit easier, like the npm package classnames
. It's function that accepts multiple class names, arrays and objects for conditional classes.
In the following example the paragraph will have the class Paragraph
(or the transformed unique name for that class respectively) and, if isLarge
is truthy, the class large
.
import cn from "classnames";
import css from "./styles.module.css";
function Component({ isLarge }) {
return <p className={cn(css.Paragraph, { [css.large]: isLarge })}></p>;
}
Even with the classnames
utility function we have some overhead of creating the final class name. The classnames
package supports a bind
version which results in less code but removes any type-safety that you otherwise have with tools like typescript-plugin-css-modules.
Solution overview
This is what we will build:
import css from "component.module.css";
// Our utility function ccn (chainable class names)
const $ = ccn(css); // Bind your class names (sorry jQuery)
function Component({ isLarge }) {
return <p className={$.Paragraph.large(isLarge).center()}>nice!</p>;
}
You can chain multiple classes together just with a dot, you can add conditional classes by calling the class name with an argument, and it works with typescript-plugin-css-modules
.
The magic: JavaScript Proxy
The main problem that we have is that we need to transform a flat object - the CSS Module object is just a key-value object, mapping each class name to their unique class name - into a nested object where each combination of class names is supported. Both, $.foo.bar()
and $.bar.foo()
should work.
The magic behind the chainable class names utility is the JavaScript Proxy object. The proxy object lets you intercept operations on an object. This allows you to do crazy things. You can manipulate values, do validation or use it for reactivity as done in Vue.
With this we don't have to create a real object with a deeply nested structure for all possible permutations. Instead, we just proxy the CSS Module object and we pretend that the object is nested.
Implementation
API
This is the API of the utility function:
// Just one class
// For the output (final class name string) you can either
// - call the name as a function without arguments
// - use the property "$"
$.Paragraph();
$.Paragraph.$;
// Multiple classes
$.Paragraph.large();
$.Paragraph.large.$;
// Conditional classes
$.Paragraph.large(isLarge)();
$.Paragraph.large(isLarge).$;
Here is a graphical representation of the structure.
When using the object $.
you can first add any of the available class names, e.g. $.Paragraph
.
Then you can get the output $.Paragraph.$
(or $.Paragraph()
), add another class $.Paragraph.large
or make the previous class conditional by calling it as a function $.Paragraph(condition)
.
TypeScript
Our utility function should be type-safe. So let's use TypeScript and create some types:
// That's our CSS Module object
type CssModuleObj = Record<string, string>;
// There are two different ways to get the final class name
// Use "$" or call it as a function without arguments
type CssModuleOutput = { $: string };
type CssModuleFnOutput = { (): string };
Next we need types for the object itself. This is a bit more complex.
// The type for conditional classes. The function requires
// the condition as argument and returns an object to add more
// classes or to get the output
type CssModuleCondition<M extends CssModuleObj> =
(condition: unknown) => CssModuleChain<M>;
// An object with all classes as keys. Each class maps to
// the callable chain. That means it can either be called
// for conditional classes ($.foo(...)) or you can just add another
// class if it's not conditional ($.foo.bar)
type CssModuleClasses<M extends CssModuleObj> = {
[key in keyof M]: CssModuleCallableChain<M>
};
// The chain combines the types above (an object with
// all class names as key) and the output types
type CssModuleChain<M extends CssModuleObj> =
CssModuleClasses<M> &
CssModuleOutput &
CssModuleFnOutput;
// This extends the type above. In addition to all class names
// and the output property / function, the object itself
// is callable to support conditional classes.
type CssModuleCallableChain<M extends CssModuleObj> =
CssModuleChain<M> & CssModuleCondition<M>;
Let's take the previous graphic and combine it with these types:
Utility function
Now that we have all types we can create our utility function. It accepts a CSS Module as argument and returns an object of type CssModuleClasses
.
Our function returns a proxy with a handler that implements the get
function. When you now access the first class it will call the init
function.
function ccn<M extends CssModuleObj>(mod: M): CssModuleClasses<M> {
function init(initialClass: string): CssModuleCallableChain<M> {
// ...
}
return new Proxy<CssModuleClasses<M>>(mod as any, {
get: (_, prop: string) => init(prop),
});
}
Now let's implement the init
function. We first create a Set
to store all chained classes. We also implement a buildFinalClassName
function that maps each chained class to their unique class name and joins them together.
function init(initialClass: string): CssModuleCallableChain<M> {
const classNames = new Set<string>();
let latestClassName: string = initialClass;
function buildFinalClassName() {
latestClassName && classNames.add(latestClassName);
return Array.from(classNames)
.map((cn) => mod[cn])
.filter((cn) => !!cn)
.join(" ");
}
}
The classNames
Set is created inside the init
function. This ensures that two separate Sets are created when you access $.foo
and $.bar
.
Next we create two proxy objects. The first one is callableChainProxy
. This proxy supports conditional classes meaning that you can call it with a condition to decide if the class you accessed just now should be added or not. In addition, you can call it without arguments to get the final class name, access $
which also returns the final class name or chain another class.
The second proxy is chainProxy
. This proxy is similar to the first one but does not support conditional classes, meaning you cannot call it with an argument (but you can call it without arguments to get the final class name).
function conditionFn(condition?: unknown) {
// ...
};
const handler: ProxyHandler<CssModuleCallableChain<M>> = {
get(_, prop: string) {
// ...
}
}
const callableChainProxy = new Proxy<CssModuleCallableChain<M>>(
conditionFn as any,
handler
);
const chainProxy = new Proxy<CssModuleChain<M>>(
buildFinalClassName as any,
handler
);
return callableChainProxy;
Let's implement the conditionFn
. The function first checks if it has received any arguments.
If not, we return the final class name (usage: $.example()
). If there is an argument,
we check if it's truthy or not and either add or remove the class name. That class that you
accessed is stored in the latestClassName
variable. We then return the chainProxy
to let you add another class.
function conditionFn(condition?: unknown) {
if (arguments.length === 0) {
return buildFinalClassName()
};
condition
? classNames.add(latestClassName)
: classNames.delete(latestClassName);
latestClassName = '';
return chainProxy;
}
And here is the implementation of the proxy handler. If you access $
it returns the final class name. Otherwise, it stores the class name in a variable named latestClassName
and returns the callableChainProxy
to let you either add another class or to call the current class with a condition (that's why we store the class name in the variable which will be used by the conditionFn
function).
const handler: ProxyHandler<CssModuleCallableChain<M>> = {
get(_, prop: string) {
if (prop === "$") return buildFinalClassName();
latestClassName = prop;
return callableChainProxy;
},
};
Proxy improvements
The root proxy has your CSS Module object as target. If you call Object.keys($)
you will get an array with all available class names. In contrast, Object.keys($.Paragraph)
will return an empty list. Why? Because the other two proxy objects have a function as target and Object.key(function() {})
also returns
an empty array.
We could improve our proxy handler and implement has
, ownKeys
and getOwnPropertyDescriptor
.
But I guess it's not really a use case to use Object.keys
on a chained class (or the in
operator), so we stick with the simple version we have.
Performance
Each time when you use $
you get the same root proxy. When you then access a class,
the init
function will be called, and it creates two proxy objects. In a component with a lot
of elements this will result in many proxy objects.
This behavior ensures that every chain you build has its own classNames
Set and its own proxy objects.
Every chain is independent of each other, and you could do something like this:
const $ = ccn({ one: "one", two: "two", three: "three" });
const x = $.one; // Start a chain
const y = $.two; // Start another chain
x.three(true); // Extend the first chain
console.log(x.$); // one three
console.log(y.$); // two
This will output "one three" and "two" which is correct. This only works because of the separated classNames
Sets. Is this really a use case we need to support? I don't think so.
In reality, you will build the chain directly on the element, like this:
<div className={$.one.three(true)}>
<p className={$.two()}></p>
</div>
With this restriction in mind we can improve the performance by reusing the classNames
Set and proxy objects. We remove the init
function and clean the classNames
Set whenever a new chain is created.
function ccn<M extends CssModuleObj>(mod: M): CssModuleClasses<M> {
// We removed the init function
// We start with an empty set
const classNames = new Set<string>();
let latestClassName: string = "";
const handler: ProxyHandler<CssModuleCallableChain<M>> = {
get(_, prop: string) {
if (prop === "$") return buildFinalClassName();
// Add the previous class to the list when chaining another class
if (latestClassName) classNames.add(latestClassName);
latestClassName = prop;
return callableChainProxy;
},
};
return new Proxy<CssModuleClasses<M>>(mod as any, {
get: (_, prop: string) => {
// Clear the list when a new chain is created
classNames.clear();
latestClassName = prop;
return callableChainProxy;
},
});
}
With this solution, we always create three proxy objects and one classNames
Set,
no matter how many chains you create. We just need to keep in mind that
a chain needs to be completed before a new one can be created.
We can also implement a check and throw an error in this case:
function buildFinalClassName() {
latestClassName && classNames.add(latestClassName);
const finalName = Array.from(classNames).map(cn => mod[cn]).filter(cn => !!cn).join(' ');
// Chain completed => clean up
classNames.clear();
latestClassName = '';
return finalName;
}
return new Proxy<CssModuleClasses<M>>(mod as any, {
get: (_, prop: string) => {
// Check if there is an uncompleted chain
if (classNames.size > 0 || latestClassName) {
throw new Error('Uncompleted class name chain');
}
latestClassName = prop;
return callableChainProxy;
}
});
Final Solution
That's it. We can now use our utility function:
const $ = ccn(css);
function Component({ isLarge }) {
return <p className={$.Paragraph.center.large(isLarge)}> </p>;
}
And you still get autocomplete suggestions:
Demo
You can find a demo and the full code on GitHub.
Summary
CSS Modules are a great way for locally scoped styling as an alternative to the various CSS-in-JS solutions. As React only accepts a string as class name it's recommended to use a utility function that supports multiple classes and conditionals.
We built a small utility function for chaining class names. It provides a short and easy-to-use syntax, supports conditional classes and does not affect type-safety as with other solutions.