I've spent the last year helping build a design system. These are some of the things I learned along the way
Building reusable components are harder than tutorials make it out to be - especially when writing components for a design system. There are tons of use cases to consider, and stakeholders you've never even met!
At my current project, I've helped flesh out a design system with React components and other functionality for some time now. You can check it out here (Norwegian only). It's not perfect, but it sure let's us write apps a lot quicker, with a lot less bugs.
In this article, I'm going to share some of my learnings from this process. They might not be ground breaking news to some, but if you're building components that people outside your team end up using, they'll thank you for reading this. Hopefully.
When you design the API of your component (that is, which props it should accept and what to name them), consider whether or not a future requirement could lead to props colliding or conflicting with each other.
Here's a Button component:
import classNames from 'classnames';
const Button = ({ primary, children }) => (
<button className={classNames('button', { 'button--primary': primary })}>{children}</button>
);
If you want a primary button, all you need to do is to pass primary={true}
! That's what you call a slick API, am I right? Nope.
It has two ways of looking - "regular" or "primary". Instinctively, this sounds fine, but what if future you realize you also need a secondary button?
Now, your component will look like this:
import classNames from 'classnames';
const Button = ({ primary, secondary, children }) => (
<button
className={classNames(
'button',
{ 'button--primary': primary },
{ 'button--secondary': secondary }
)}
>
{children}
</button>
);
Now, that's a potential bug. What if some less experienced developer starts applying both modifiers at once?
<Button primary secondary>
ALL OF YOUR BASE
</Button>
Instead, introduce a buttonType
property, which accepts a string value of either primary
, secondary
or whatever future you decides is appropriate. This has proven to work great for us, and I think it'll work great for you too!
import classNames from 'classnames';
const Button = ({ buttonType, children }) => (
<button
className={classNames(
'button',
{ 'button--primary': buttonType === 'primary' },
{ 'button--secondary': buttonType === 'secondary' }
)}
>
{children}
</button>
);
When I first started out as a developer, I was taught the somewhat charming slogan that "assumptions are the mother of all fuck-ups". One thing we often assume when we create reusable components, is the context they will be used in.
In HTML, some contexts require different semantics. If we return to our button, we can't really use it if we want to link to something. I mean, technically we can, but we can't use an <a />
tag. Let's fix that.
By convention, I always support an as
prop, which either accepts a component or a string.
const Button = ({ as: Element, buttonType, children }) => (
<Element className={/* as before*/}>
{children}
</Element>
);
Button.defaultProps = {
as: 'button',
};
Here, I'm renaming the as
prop to Element
(just for clarity's sake), and refering to that component instead of the hard coded button
. Since I'm defaulting the as
prop to be button
, things work just as before if I don't specify as
.
If I later want to use the Button component with a <a />
tag or a <Link />
tag (from react-router
, for instance), I can do so without creating a new component!
<Button as="a" href="https://react.christmas">Go learn stuff</Button>
<Button as={Link} to="/">Go home</Button>
<Button buttonType="primary">Buy stuff</Button>
When you at first implement a component (particularly lower-level ones like buttons, typography etc), you're not going to have a clue about what future you are going to throw at it. In that case - assume nothing, and just apply any non-API props to the root element!
const Button = ({ as: Element, buttonType, children, ...rest }) => (
<Element className={/* as before */} {...rest}>
{children}
</Element>
);
or, even simpler (since children
is a regular prop, too):
const Button = ({ as: Element, buttonType, ...rest }) => (
<Element className={/* as before */} {...rest} />
);
This way, when future you (or your colleagues) need support for onMouseEnter
, tabIndex
or aria-describedby
, you don't need to go and add another prop for that. Just spread whatever the user passes in to your root element, and you should be good to go.
One final thing to remember is className
. className
is a prop that's typically set from the get-go, and in those cases, you should make sure to allow the consumer to add to the existing class names, not overwrite them. You can fix that by applying it like this:
const Button = ({ as: Element, buttonType, className, ...rest }) => (
<Element
className={classNames(
'button',
{ 'button--primary': buttonType === 'primary' },
{ 'button--secondary': buttonType === 'secondary' },
className
)}
{...rest}
/>
);
When we started out with our design system, we tried to be clever, and rename existing DOM props to make them more in line with the rest. That is a terrible idea.
Buttons, for example, have a type
property. It's what decides whether a button submits a form, resets it, or does nothing at all. However, "type" is a pretty usable name - for example if we wanted to specify the visual type, like above.
It might be tempting to "repurpose" DOM properties, because they might not make sense in the moment. But stay away from them! Changing from type
to buttonType
at a later time, for instance, is a really annoying breaking change that's going to piss off your consumers. Believe me - I did exactly that.
The last foot-gun I'm going to warn you about today, is the fallacy that several ways of providing a value is convenient. Take our trusted ol' <Button />
. When we first created it, we provided the content as a label
prop. Then, as time went by, some clever developer (probably me), added the possibility of sending in content via the children
prop. However, our implementation now looked like this:
const Button = ({
as: Element,
children,
label,
...rest
}) => (
<Element
className={/* as before*/}
{...rest}
>
{label || children}
</Element>
);
Now, we had to explain to all consumers that you could do both, but if you did, label
would take presedence over children
. That led to much more confusion than it solved.
So don't do that. Use children
for content if possible, and never allow for several ways of doing the same thing.
Another example of this is when we needed to add some invalid styles to input fields. We added an invalid
prop, and set the aria-invalid
tag based on its value:
const InputField = ({ invalid, ...rest }) => (
<input aria-invalid={invalid ? 'true' : 'false'} {...rest} />
);
Seems fair enough - but you could actually do the same by letting the consumer pass the aria-invalid
prop themselves! This is a known API from HTML, and almost as convenient too.
I hope this article has given you some new ideas as to how you could structure your components and their APIs. As an added bonus, if you follow these principles in your apps as well, you end up refactoring less, and reusing more.
Thanks for reading!
All rights reserved © 2024