Replicate the classNames function
I am pretty sure that if you’re reading this article, you are a FrontEnd Developer, and if your focus is on the React library, the package classnames
(or class
) should ring a bell.
If it doesn’t, well probably you haven’t such need yet or maybe you’re deep into Styled Components.
It does not even matter. In the third challenge of the GFE 75 interview questions that you can find in the GreatFrontEnd platform, you’re gonna build your own. All you have to understand now is what this function does.
What classnames
package do?
In JSX if you have the need to conditionally apply a class
to an HTML element, one of the quickest approaches it to turn the className
string into a template string and use ternaries to add/remove the class based on the component state.
Here’s an example on how it could be accomplished:
export default function Text({
text,
isMobile = false,
bgColor = 'red',
}: {
text: string;
isMobile: boolean;
bgColor: 'blue' | 'red' | 'green';
}) {
return (
<p
className={`text-white flex gap-4
${isMobile && 'flex-col'}
${bgColor === 'blue' ? 'bg-blue-400' : 'bg-stone-400'}
`}
>
{text}
</p>
);
}
This little example is also probably wrong because I don’t properly check that each class added will have a space in front of it. But this is one of the reasons that pushed me away from this kind of approach.
Generally speaking, while there’s nothing wrong with it, I like to reduce the number or complexity of ternary operators I use in my JSX, especially if they grow.
In the specific case of classnames
, we have a powerful function that’s able to take different data structures and return a simple string of classes that we can attach to our HTML element.
The same paragraph that we used earlier, could be written like so:
export default function Text({
text,
isMobile = false,
bgColor = 'red',
}: {
text: string;
isMobile: boolean;
bgColor: 'blue' | 'red' | 'green';
}) {
const pClass = classNames('text-white flex gap-4 bg-stone-400', {
'flex-col': isMobile,
'bg-blue-400': bgColor === 'blue',
'bg-green-400': bgColor === 'green',
});
return (
<p className={pClass}>
{text}
</p>
);
}
As you can see, this syntax separates the logic of building the string of classes for the element from the actual part where we use it.
Besides the fact that you can always put it inline if you do not like this approach, the thing that I find really powerful is that classNames
can accept any kind of data as an argument. In the example, we saw how it handles string
and objects
, but it can also accept array
and recursively iterate over it.
I suggest you to have a look at the Usage section of the package description, you’ll also discover that we can also have dynamic classes that read the value from the component props or state!
Enough talking, let’s check with what code we get welcomed from the challenge.
Use Cases
classNames('foo', 'bar'); // 'foo bar'
classNames('foo', { bar: true }); // 'foo bar'
classNames({ 'foo-bar': true }); // 'foo-bar'
classNames({ 'foo-bar': false }); // ''
classNames({ foo: true }, { bar: true }); // 'foo bar'
classNames({ foo: true, bar: true }); // 'foo bar'
classNames({ foo: true, bar: false, qux: true }); // 'foo qux'
classNames(['foo', 'bar', 'qux']) // 'foo bar qux'
classNames('foo', ['bar', { qux: false, baz: true }]); // 'foo bar baz
These examples should give you a hint on how the classNames
function works internally. In the case of a string
or number
, the function simply converts the numbers and returns a string, with each argument separated by a space.
If in the list of arguments it finds an object
, it then loops over each of its keys (that will also be used as the class string itself) and adds to the returning string only the keys that have a value of true
.
If it’s an array instead, it must loop over each item and behave according to the implementation we just defined.
I already started to have some ideas about it, then I checked the starting code:
export type ClassValue =
| ClassArray
| ClassDictionary
| string
| number
| null
| boolean
| undefined;
export type ClassDictionary = Record<string, any>;
export type ClassArray = Array<ClassValue>;
export default function classNames(...args: Array<ClassValue>): string {
throw 'Not implemented!';
}
Don’t get distracted by the extended type definitions. What caught my attention was the presence of the ...args
rest operator that made classNames
accept an array containing all the parameters passed to the function. While I hinted at you before, this moment changed the way I wanted to call classNames
recursively, since I was accepting an array of possible values, I had to handle the case that the first item of args
is an array itself.
Instead of recursively calling classNames
, I created a closure function that gets called on each item of args
.
export default function classNames(...args: Array<ClassValue>): string {
// The variable holding the list of classes
let classnames: string[] = []
// The closure that will update the list of classes
function processItem(item: ClassValue) {
// 1. Check if item is not falsy, aka we can work with the value
// 2. Define actions on item type. It's the recursive function.
// 2.a. If array, recursively call processItem
// 2.b. If object, iterate over its keys and conditionally add new classes
// 2.c. If number or string, just add the class
}
// Call processItem for each item in args
args.forEach(processItem);
return classnames.join(' ')
}
Following the plan let’s start to implement each step, starting by the check for falsy values.
function processItem(item: ClassValue) {
if (!item) return;
}
Now that we have a value that we can work on, it’s time to check its type and decide the next step to take.
I thought of creating a utility function able to return a string able to tell me if the value was an array
, object
, string
, or number
, but in the end, I was just duplicating the logic for a minor advantage.
So, I decided to keep things simple and leave the logic inside the function:
function processItem(item: ClassValue) {
if (!item) return;
// 2. Block to define processes based on item type, it's the recursive function
if (Array.isArray(item)) {
// 2.a. If array, recursively call processItem
} else if (typeof item === 'object') {
// 2.b. If object, iterate over its keys and conditionally add new classes
} else if (typeof item === 'string' || typeof item === 'number') {
// 2.c. If number or string, just add the class
}
}
Let’s start with the simplest thing: what to do if the item
is a string
or a number
. In this case, all we need to do is add the value to the classnames
array, making sure it’s a string
.
/* ... */
} else if (typeof item === 'string' || typeof item === 'number') {
classnames.push(item.toString());
}
/* ... */
As simple as that.
Now we move one step above, and we start to handle the case where an item
is an object
.
This check came after the
Array.isArray(item)
check because if we were to do atypeof item === 'object'
on an array, we set the wrong logic since Array is an object type.
/* ... */
} else if (typeof item === 'object') {
Object.entries(item).forEach(([key, value]) => {
if (value) classnames.push(key);
});
}
/* else if('string' || 'number") */
/* ... */
This snippet does a lot, but I didn’t feel of creating variables because I thought it was straightforward, let’s check what it does anyway:
-
We convert all proprieties inside the object into an array, thanks
Object.entries
. -
We loop over each item and destructure its values as
key
andvalue
.key
will be the class name/s we want to apply whilevalue
will coerced into a truthy or falsy value. -
In case
value
is truthy, we add the item toclassnames
array.
Now, for the part you were waiting for, what do we do if the item
is an array and it holds multiple values we want to process?
Well, it’s time for some recursion!
You heard that right, in case item
is an array, we recursively call prosessItem
to loop over each item contained and decide the action in case item
is an object
, a string
, a number
, or a new Array itself!
if (Array.isArray(item)) {
item.forEach(processItem);
}
Thanks to the Array.isArray
built-in method, I will ensure that the item
I am currently processing is an array. Then, I will pass each value into the same processItem
function that I’ve defined.
The check will start again, ending in a value I can add to classnames
.
If you’re curious about
item.forEach(processItem)
call, instead of relying on something likeitem.forEach(subItem => processItem(subItem))
I want to tell you that this is not magic. Many built-in array methods automatically pass arguments to the callback functions we use, and since we referenceprocessItem
, we pass all the attributes to it. The ones that the function will not use are ignored.
Now that we have handled all the cases it’s time to check the entire classNames
function once more:
export type ClassValue =
| ClassArray
| ClassDictionary
| string
| number
| null
| boolean
| undefined;
export type ClassDictionary = Record<string, any>;
export type ClassArray = Array<ClassValue>;
export default function classNames(...args: Array<ClassValue>): string {
let classnames: string[] = []
function processItem(item: ClassValue) {
if(!item) return;
if (Array.isArray(item)) {
item.forEach(processItem);
} else if (typeof item === 'object') {
Object.entries(item).forEach(([key, value]) => {
if (value) classnames.push(key);
});
} else if (typeof item === 'string' || typeof item === 'number') {
classnames.push(item.toString());
}
}
args.forEach(processItem);
return classnames.join(' ')
}
Compare with proposed solutions
If this is not the first article you have read about my GreatFrontEnd experience, you know that after describing how I came up with my solution, I like to dive deep into the platform’s proposed solutions.
It’s a good exercise that allows me to reflect on the code I just wrote, analyze it, and discover different approaches.
In this case, the platform proposes three different solutions and a follow-up (in case you want to filter out duplicate classes). I will not share here each solution, in the end this challenge is even free so as usual my advice is just to create an account on GreatFrontEnd and start practicing.
What I’ll do instead is to summarize the different approaches and talk about the main difference between my code and the solutions:
-
Pure recursive function: the first solution involves the call of
classNames
itself. That has been my first attempt, but while I was typing a TypeScript error about the arguments made me doubt about it. Happy to say that the proposed solution works well, and probably my was just a typo. -
Inner recursive helper that modifies an external value: this has been my approach in this article. Even though inside the solution we have the
classNamesImpl
closure, it pratically does the same of ourprocessItem
one. -
Inner recursive helper that modifies the argument: even though this is still a closure, as the previous approach, here we pass the
classnames
value to each call of theclassNamesImpl
, making the relation explicit with the array that holds all classes and passing the new one to the next call ofclassNamesImpl
.
Let’s talk about the findings that I had while comparing my code with the proposed solutions.
I am quite happy that, while I do not recursively call classNames
, my solution is close to the one proposed by the first approach. However, I also found some approaches, which are also repeated in other solutions, that could have improved the readability of my code.
First and foremost, we need to talk again about talking variable names.
All the proposed solutions have const argType = typeof item
. Instead of repeating typeof item ===
at each check, the developer that proposed the solutions decided to create a simple variable that held such value and reduced the complexity of each check while reducing the need for comments in the code.
An approach that I found interesting is that for the second and third solutions, the ones where we create a named inner recursive helper function, the loop args.forEach
is present inside the function itself.
export default function classNames(...args: Array<ClassValue>): string {
let classnames: string[] = []
function classNamesImpl(...args: Array<ClassValue>) {
args.forEach((arg) => {
// Apply logic on arg type
})
}
// Call implementation by spreading args
classNamesImpl(...args);
// Return a string of classnames
return classnames.join(' ');
}
While this is a valid approach, I preferred to keep the loop separate from the function and make it process just a single item.
If you aim to maximize compatibility with older browsers that run pre-ES2017 syntax, instead of Object.entries
, you could leverage a different approach when handling objects. However, I do not want to ruin all the fun, so I invite you once more to solve the challenge yourself (or just check the solutions).