Hopp til hovedinnhold

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.

Don't allow conflicting props

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>
);

Always allow for element substitution

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>

Pass all unspecified props

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}
  />
);

Never rename existing DOM-props!

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.

Only allow one way of doing things

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.

Go create reusable stuff!

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!

Did you like the post?

Feel free to share it with friends and colleagues