An opinionated guide to building unopinionated components
Atomic design
Atomic Design is awesome, and it’s taken me a long time to properly understand it.
In this post we’ll look at some (super opinionated) best practises for building un-opinionated, flexible, and composable atoms, as well as ways to make sure your molecules and organisms are flexible enough to meet all of your use-cases.
Atomic design as it applies to a design system
- ⚛️ Atoms are the building blocks of your application. Because of this, these are the most crucial part of your component library to get right.
- 🧬 Molecules and organisms, just like atoms, should be as flexible and un-opinionated as possible - within reason.
- 🧑🏽🔬 Templates and Pages are the highest level concepts in Atomic Design and, in a design system, are often interchangable. This is where the majority of your logic will live.
A collection of learnings
Props
Atoms should be as lightweight as possible. More often than not they should just be a styled version of a single HTML element, and they should behave in exactly the same way as the HTML element they extend. This means that they should accept all of the same props that the HTML element accepts, plus a small handful more which might be used for styling. To make building these components simpler, your component should avoid renaming props wherever possible.
// Avoid doing this
export const BadButton = ({
level = 'primary',
appearance = 'default',
handleClick = () => {},
text,
isDisabled = false,
}) => {
return (
<button
level={level}
appearance={appearance}
onClick={handleClick}
disabled={isDisabled}
>
{text}
</button>
);
};
/*
* Instead, do this.
* Note how we explicitly list ONLY the custom props, then
* gather all other props to spread in to the button.
*/
export const BetterButton = ({
level = 'primary',
appearance = 'default',
...props
}) => {
return (
<button
level={level}
appearance={appearance}
{...props}
/>
);
};
Typing
If you’ve seen the light and have decided to build your components with TypeScript, then you can take advantage of React’s HTMLAttributes
types to make correctly typing your atoms a breeze!
As an aside, decide early-on whether you will use only type
or a combination of type
and interface
. I recommend ignoring interface
completely and relying exclusively on type
. They’re sufficiently similar that there are no real downsides to this, with the upside being consistency.
import { ButtonHTMLAttributes } from 'react';
/*
* Start by including all standard HTML button props,
* then layer in any custom props on top of that.
*/
type TButtonProps =
ButtonHTMLAttributes<HTMLButtonElement> & {
level?: 'primary' | 'secondary' | 'tertiary';
appearance?: 'default' | 'alternative' | 'danger';
};
export const BetterButton = ({
level = 'primary',
appearance = 'default',
...props
}: TButtonProps) => {
return (
<button
level={level}
appearance={appearance}
{...props}
/>
);
};
Margins
Generally speaking, avoid making your atoms responsible for their own margins. There may be exceptions to this rule, but more often than not having margin baked into an atom results in that margin having to be manually overriden by the implementer. This can lead to inconsistent margin across components, and can cause headaches when margins need to be changed in the future.
The majority of the time, the only things using your atoms will be your molecules. As they are responsible for one specific implementation of your atoms, let them be responsible for setting any margins.
Accessibility
Accessibility is hard. Most of the time, the components you’re building have been built thousands of times before and, fortunately, some very clever people have provided us the tools to make your components extremely accessible with very little work required from us. Libraries like Headless UI, Reakit, and Reach UI all provide either hooks or unstyled components which cater for many of the most common custom interactive elements that you end up building; modals, tab views, dropdowns, etc. Lean heavily on these libraries to make sure the components you’re building are accessible.
Declarative
Defining declarative views is a core underpinning of React, as well as HTML. Avoid passing arrays of data to a component only to have that component map through the data and render a different component. Instead, have the parent component accept children (or a render prop) and make the parent responsible for mapping over and rendering the items. This will allow you to lift up your logic and help to decouple the child component from the parent.
// HTML works like this
<select name="pets">
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="hamster">Hamster</option>
<option value="parrot">Parrot</option>
</select>;
// NOT like this
<select
name="pets"
data={[
{ value: 'dog', label: 'Dog' },
{ value: 'cat', label: 'Cat' },
{ value: 'hamster', label: 'Hamster' },
{ value: 'parrot', label: 'Parrot' },
]}
/>;
const items = ['Apple', 'Banana', 'Orange'];
// So don't do this
const List = ({ items }) => (
<ul>
{items.map(item => (
<li>{item}</li>
))}
</ul>
);
const App = () => (
<div>
<List items={items} />
</div>
);
// Instead, do this
const List = ({ children }) => <ul>{children}</ul>;
const App = () => (
<div>
<List>
{items.map(item => (
<li>{item}</li>
))}
</List>
</div>
);
You'll notice in that second example that our List component is now just a <ul>
and nothing more, almost negating the need for it to be its own component at all. In practise it wouldn't just accept children (see the sections on Typing and Props) and instead would take all of the standard <ul>
props.
Although this is a greatly simplified example, in my experience building components in this composable, declarative way often leads to a smaller number of components overall as you realise that some components simply don't need to exist.
Render props
Hey, remember render props? They’re so 2018, am I right?
Well, it turns out they’re still really useful! Hooks replaced many of the uses for render props, but they can still help you build more composable components.
const FormElement = ({
renderLabel = props => <label {...props} />,
renderInput = props => <input {...props} />,
}) => {
const id = useRandomId();
return (
<div>
{renderLabel({ htmlFor: id })}
{renderInput({ id })}
</div>
);
};
This example shows why I'd advocate for having render props accept functions which return JSX, rather than just JSX themselves. It allows the child component (in this case the FormElement
) to pass props back up to the implementor. In this example the FormElement
handles generating and setting a random ID on both the label and the input so the implementor doesn't have to. Instead the implementor can just consume those props.
/**
* The input and label elements will now have
* matching id & htmlFor props applied respectively
*/
const App = () => (
<FormElement
renderLabel={props => <Label {...props} />}
renderInput={props => <Input {...props} />}
/>
);
Custom hooks
Aim to pull component logic out into custom hooks. This allows you to more easily group related pieces of logic together into well-named hooks, and also makes it easier to test those hooks separately from your component.
// Don't do this (contrived example!)
const MyComponent = () => {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
// ...
};
// Instead, do this
const useHasMounted = () => {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
return hasMounted;
};
const MyComponent = () => {
const hasMounted = useHasMounted();
// ...
};
Storybook
Every shared component should have an associated set of Stories, accessible through Storybook. The stories should document each of the custom props that your component accepts, and can show some common use-cases for your components too. You can even take advantage of storyshots when writing tests to automatically flag when a component’s rendered output changes.
Theming
Have a central theme for your components that is the single source of truth for colours, typography, spacing, breakpoints, shadows, etc. This can be defined as a styled-components theme, a tailwind config, it doesn’t really matter, as long as there’s a single source of truth.
This means you should avoid using arbitrary values within components as much as possible - those values should come from the theme.
Once you have a design system in place you could take this a step further by abstracting your design tokens into some other centralised (and language-agnostic) location that both the design team and the engineering team can pull from.