The 10 Component Commandments
selbekk
Posted on June 22, 2019
Written in collaboration with Caroline Odden. Based on the talk with the same name and people, held at the ReactJS Oslo Meetup in June 2019.
Creating components that are used by a lot of people is hard. You have to think pretty carefully about what props you should accept, if those props are supposed to be part of a public API.
This article will give you a quick introduction to some best practices within API design in general, as well as the definite list of 10 practical commandments you can use to create components that your fellow developers will love to use.
What's an API?
An API - or Application Programming Interface - is basically where two pieces of code meet. It's the contact surface between your code and the rest of the world. We call this contact surface an interface. It's a defined set of actions or data points you can interact with.
The interface between your backend and your frontend is an API. You can access a given set of data and functionality by interacting with this API.
The interface between a class and the code calling that class is an API, too. You can call methods on the class, to retrieve data or trigger functionality encapsulated within it.
Following the same train of thought, the props your component accept is also its API. It's the way your users interact with your component, and a lot of the same rules and considerations applies when you decide what to expose.
Some best practices in API design
So what rules and considerations apply when designing an API? Well, we did a bit of research on that end, and turns out there's a lot of great resources out there. We picked out two - Josh Tauberer's "What Makes a Good API?" and Ron Kurir's article with the same title - and we came up with 4 best practices to follow.
Stable versioning
One of the most important things to consider when you're creating an API, is to keep it as stable as possible. That means minimizing the amount of breaking changes over time. If you do have breaking changes, make sure to write extensive upgrade guides, and if possible, provide a code-mod that automates that process for the consumer.
If you're publishing your API, make sure to adhere to Semantic Versioning. This makes it easy for the consumer to decide what version is required.
Descriptive error messages
Whenever an error occurs when calling your API, you should do your best to explain what went wrong, and how to fix it. Shaming the consumer with a "wrong usage" response without any other context doesn't seem like a great user experience.
Instead, write descriptive errors that help the user fix how they call your API.
Minimize developer surprise
Developers are flimsy beings, and you don't want to startle them when they are using your API. In other words - make your API as intuitive as possible. You can achieve that by following best practices and existing naming conventions.
Another thing to keep in mind is being consistent with your code. If you're prepending boolean property names with is
or has
one place, and skip it the next - that's going to be confusing to people.
Minimize your API surface
While we're speaking of minimizing stuff - minimize your API as well. Tons of features are all well and good, but the less surface your API has, the less your consumers will have to learn. That - in turn - is perceived as an easy API to use!
There are always ways to control the size of your APIs - one is to refactor out a new API from your old one.
The 10 Component Commandments
So these 4 golden rules work well for REST APIs and old procedural stuff in Pascal - but how do they translate to the modern world of React?
Well, as we mentioned earlier, components have their own APIs. We call them props
, and it's how we feed our components with data, callbacks and other functionality. How do we structure this props
object is such a way that we don't violate any of the rules above? How do we write our components in such a way that they're easy to work with for the next developer testing them out?
We've created this list of 10 good rules to follow when you're creating your components, and we hope you find them useful.
1. Document the usage
If you don't document how your component is supposed to be used, it's by definition useless. Well, almost - the consumer could always check out the implementation, but that's rarely the best user experience.
There are several ways to document components, but in our view there are 3 options that we want to recommend:
The first two give you a playground to work with while developing your components, while the third one let's you write more free-form documentation with MDX.
No matter what you choose - make sure to document both the API, as well as how and when your component is supposed to be used. That last part is crucial in shared component libraries - so people use the right button or layout grid in a given context.
2. Allow for contextual semantics
HTML is a language for structuring information in a semantic way. Yet - most of our components are made out of <div />
tags. It makes sense in a way - because generic components can't really assume whether it's supposed to be an <article />
or <section />
or an <aside />
- but it isn't ideal.
Instead, we suggest that you allow your components to accept an as
prop, which will consistently let you override what DOM element is being rendered. Here's an example of how you could implement it:
function Grid({ as: Element, ...props }) {
return <Element className="grid" {...props} />
}
Grid.defaultProps = {
as: 'div',
};
We rename the as
prop to a local variable Element
, and use that in our JSX. We give a generic default value for when you don't really have a more semantic HTML tag to pass.
When time comes to use this <Grid />
component, you could just pass the correct tag:
function App() {
return (
<Grid as="main">
<MoreContent />
</Grid>
);
}
Note that this will work just as well with React components. A great example here is if you want to have a <Button />
component render a React Router <Link />
instead:
<Button as={Link} to="/profile">
Go to Profile
</Button>
3. Avoid boolean props
Boolean props sound like a great idea. You can specify them without a value, so they look really elegant:
<Button large>BUY NOW!</Button>
But even if they look pretty, boolean properties only allow for two possibilities. On or off. Visible or hidden. 1 or 0.
Whenever you start introducing boolean properties for stuff like size, variants, colors or anything that might be anything other than a binary choice down the line, you're in trouble.
<Button large small primary disabled secondary>
WHAT AM I??
</Button>
In other words, boolean properties often doesn't scale with changing requirements. Instead - try to use enumerated values like strings for values that might have a chance to become anything other than a binary choice.
<Button variant="primary" size="large">
I am primarily a large button
</Button>
That's not to say that boolean properties doesn't have a place. They sure do! The disabled
prop I listed above should still be a boolean - because there is no middle state between enabled and disabled. Just save them for the truly binary choices.
4. Use props.children
React has a few special properties that are dealt with in a different way than the others. One is key
, which are required for tracking the order of list items, and another one is children
.
Anything you put between an opening and a closing component tag is placed inside the props.children
prop. And you should use that as often as you can.
The reason for this is that it's much easier to use than having a content
prop or something else that typically only accepts a simple value like text.
<TableCell content="Some text" />
// vs
<TableCell>Some text</TableCell>
There are several upsides to using props.children
. First of all, it resembles how regular HTML works. Second, you're free to pass in whatever you want! Instead of adding leftIcon
and rightIcon
props to your component - just pass them in as a part of the props.children
prop:
<TableCell>
<ImportantIcon /> Some text
</TableCell>
You could always argue that your component should only be allowed to render regular text, and in some cases that might be true. At least for now. By using props.children
instead, you're future proofing your API for these changing requirements.
5. Let the parent hook into internal logic
Some times we create components with a lot of internal logic and state - like auto-complete dropdowns or interactive charts.
These types of components are the ones that most often suffer from verbose APIs, and one of the reasons is the amount of overrides and special usage you usually have to support as time goes by.
What if we could just provide a single, standardized prop that could let the consumer control, react to or plain override the default behavior of your component?
Kent C. Dodds wrote a great article on this concept called "state reducers". There's a post about the concept itself, and another one on how to implement it for React hooks.
Quickly summarized, this pattern of passing in a "state reducer" function to your component will let the consumer access all the actions dispatched inside of your component. You could change the state, or trigger side-effects even. It's a great way to allow for a high level of customization, without all the props.
Here's how it could look:
function MyCustomDropdown(props) {
const stateReducer = (state, action) => {
if (action.type === Dropdown.actions.CLOSE) {
buttonRef.current.focus();
}
};
return (
<>
<Dropdown stateReducer={stateReducer} {...props} />
<Button ref={buttonRef}>Open</Button>
</>
}
You can of course create simpler ways of reacting to events, by the way. Providing an onClose
prop in the previous example would probably make for a better user experience. Save the state reducer pattern for when it's required.
6. Spread the remaining props
Whenever you create a new component - make sure to spread the remaining props onto whatever element makes sense.
You don't have to keep on adding props to your component that's just going to be passed on to the underlying component or element. This will make your API more stable, removing the need for tons of minor version bumps for whenever the next developer needs a new event listener or aria-tag.
You can do it like this:
function ToolTip({ isVisible, ...rest }) {
return isVisible ? <span role="tooltip" {...rest} /> : null;
}
Whenever your component is passing a prop in your implementation, like a class name or an onClick
handler, make sure the external consumer can do the same thing. In the case of a class, you can simply append the class prop with the handly classnames
npm package (or simple string concatenation):
import classNames from 'classnames';
function ToolTip(props) {
return (
<span
{...props}
className={classNames('tooltip', props.tooltip)}
/>
}
In the case of click handlers and other callbacks, you can combine them into a single function with a small utility. Here's one way of doing it:
function combine(...functions) {
return (...args) =>
functions
.filter(func => typeof func === 'function')
.forEach(func => func(...args));
}
Here, we create a function that accepts your list of functions to combine. It returns a new callback that calls them all in turn with the same arguments.
You'd use it like this:
function ToolTip(props) {
const [isVisible, setVisible] = React.useState(false);
return (
<span
{...props}
className={classNames('tooltip', props.className)}
onMouseIn={combine(() => setVisible(true), props.onMouseIn)}
onMouseOut={combine(() => setVisible(false), props.onMouseOut)}
/>
);
}
7. Give sufficient defaults
Whenever you can, make sure to provide sufficient defaults for your props. This way, you can minimize the amount of props you have to pass - and it simplifies your implementation a great deal.
Take the example of an onClick
handler. If you're not requiring one in your code, provide a noop-function as a default prop. This way, you can call it in your code as if it was always provided.
Another example could be for a custom input. Assume the input string is an empty string, unless provided explicitly. This will let you make sure you're always dealing with a string object, instead of something that's undefined or null.
8. Don't rename HTML attributes
HTML as a language comes with its own props - or attributes, and it is in itself the API of the HTML elements. Why not keep using this API?
As we mentioned earlier, minimizing the API surface and making it somewhat intuitive are two great ways of improving your component APIs. So instead of creating your own screenReaderLabel
prop, why not just use the aria-label
API already provided to you?
So stay away from renaming any existing HTML attributes for your own "ease of use". You're not even replacing the existing API with a new one - you're adding your own on top. People could still pass aria-label
alongside your screenReaderLabel
prop - and what should be the final value then?
As an aside, make sure to never override HTML attributes in your components. A great example is the <button />
element's type
attribute. It can be submit
(the default), button
or reset
. However, a lot of developers tend to re-purpose this prop name to mean the visual type of button (primary
, cta
and so on).
By repurposing this prop, you have to add another override to set the actual type
attribute, and it only leads to confusion, doubt and sore users.
Believe me - I've done this mistake time and time again - it's a real booger of a decision to live with.
9. Write prop types (or types)
No documentation is as good as documentation that lives inside your code. React comes fully kitted out with a great way to declare your component APIs with the prop-types
package. Now, go use it.
You can specify any kind of requirement to the shape and form of your required and optional props, and you can even improve it further with JSDoc comments.
If you skip a required prop, or pass an invalid or unexpected value, you'll get runtime warnings in your console. It's great for development, and can be stripped away from your production build.
If you're writing your React apps in TypeScript or with Flow, you get this kind of API documentation as a language feature instead. This leads to even better tooling support, and a great user experience.
If you're not using typed JavaScript yourself, you should still consider providing type definitions for those consumers that do. This way, they'll be able to use your components much more easily.
10. Design for the developers
Finally, the most important rule to follow. Make sure your API and "component experience" is optimized for the people that will use it - your fellow developers.
One way to improve this developer experience is to provide ample error messages for invalid usage, as well as development-only warnings for when there are better ways to use your component.
When writing your errors and warnings, make sure to reference your documentation with links or provide simple code examples. The quicker the consumer can figure out what's wrong and how to fix it, the better your component will feel to work with.
Turns out, having all of these lengthy errors warnings doesn't affect your final bundle size at all. Thanks to the wonders of dead code elimination, all of this text and error code can be removed when building for production.
One library that does this incredibly well is React itself. Whenever you forget to specify a key for your list items, or misspell a lifecycle method, forget to extend the right base class or call hooks in an indeterminate way - you get big thick error messages in the console. Why should the users of your components expect anything less?
So design for your future users. Design for yourself in 5 weeks. Design for the poor suckers that have to maintain your code when you're gone! Design for the developer.
A recap
There are tons of great stuff we can learn from classic API design. By following the tips, tricks, rules and commandments in this article, you should be able to create components that are easy to use, simple to maintain, intuitive to use and extremely flexible when they need to be.
What are some of your favorite tips for creating cool components?
Posted on June 22, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.