Component API Design
guidelines for building shared components
This is a high-level description of API guidelines for shared components. It is intended to provide a general framework for creating resilient and adaptable components. These guidelines are derived from three main concepts:
- Make the right choices easy
- Make incorrect choices obvious
- Provide helpful off-ramps
Make the Correct Choices Easy
Documentation is great, but if users are constantly scouring the docs and source code to remember how to use shared components, that’s an indication of inconsistent APIs. Ideally, the library should teach you how to use it as you interact with it. There’s an interaction design hierarchy that describes this well:
Interactions should be first intuitive, then discoverable, and last standardized. *
* Summarized from The Design of Everyday Things by Don Norman
Note: While there is a hierarchy of preference, these qualities are also interdependent. We should design our components to be intuitive and discoverable and standardized.
Building intuitive APIs is challenging. You usually have to rely on an existing natural mapping or some prior context. But even then, there will almost always be some amount of time required for memory to be established. However, there are a few things we can do to make APIs more intuitive and guessable:
Shared APIs across common components
Creating shared APIs across components reduces the amount of context needed users to become proficient with the library. While more complex components will have inevitably have more component-specific props, there are ways to consolidate them. For example:
This Accordion component uses a component-specific object to structure its contents.
FeatureList also has its own component-specific object, but it uses the same
Lower-level components will have more common props that can be used to modify them. For example:
This is a standard Button using the
danger variant to modify its styling.
<Button variant="danger">Cancel Your account</Button>
Alert also uses the
variant prop and
Leveraging existing APIs
We can also leverage familiar, existing APIs to reduce cognitive load. For example:
This AccordionTitle component uses React’s
children prop to render child components (as opposed to using a custom
title prop. This might seem like a minor point, but I've found that as your component library grows, remembering one-off prop names such as
title becomes much more challenging.
We can also leverage familiar CSS style attributes and HTML attributes to reduce context:
Discoverability is a powerful tool that helps users learn the library while using it. We can accomplish this in several ways:
Note: While there is a preferred hierarchy, each is interdependent. We should incorporate self-disclosure, documentation, and readable source code into our library.
This is the most preferred option for discoverability. Components self-disclosing or hinting at their API provides guidance in the user’s immediate context. There’s no need to hop into external documentation or source code to figure out how to interact with them.
TypeScript and IDE plugins provide helpful mechanisms that aid self-disclosure. Whenever possible, we should leverage these options first.
Along with self-disclosing component API, we should provide external documentation. Component docs are intended to support direct-users (engineers and designers) They should include information for using the API, but also higher-level information about when to use the component.
Source code should be a last resort for discoverability. If users are regularly digging into code to understand how to use a component, that’s a flag for poor design. However, these situations do occur, and we should plan for them by optimizing readability. If unintuitive choices had to be made or a temporary patch had to be employed, we should add a comment alongside that code. We should also file an issue for that problem if one does not already exist. Another way to make source code more readable is a standard file structure, which will be discussed in another post.
Ideally, we should build standards around intuitive choices (common API patterns and leveraging existing API conventions), but sometimes those choices are not clear. In those situations, we should create well-documented standards for those APIs. It’s important to remember that each new standard is additional context a user will need to keep in their head to interact with the library. We should keep limit the number of standards to reduce cognitive load for users.
Make incorrect choices obvious
Non-obvious mistakes are a significant source of friction when interacting with components. Someone unknowingly misuses a component and it passes code review, only to realize the mistake much later. This could be a misleading prop or a non-standard modification to the component’s structure of styling. The best way to reduce mistakes is by following the guidelines above and making the correct choices easy. If our components are intuitive and discoverable, we should reduce the number of mistakes overall. And if our standards are clear enough, misuse should be clear in code review. However, there are other options for making incorrect choices obvious.
Typing props not only provides discoverability, but also warnings during misuse. We should make types specific as possible without being overbearing. An example would be listing out Badge variants in a type instead of simply allowing strings.
Consistent styling patterns
One of the most common ways to misuse a component is styling. While users are often blamed for these mistakes, it’s often a symptom of vague patterns for modifying styles. Providing consistent and clear styling patterns helps users make the correct choice but also makes misuse obvious in code review.
All code has a half-life and deprecations happen. We should provide helpful warnings to users when a component or prop is being deprecated and how to migrate. There should also be levels of deprecation to help users prioritize updates.
Provide helpful off-ramps
When you drive along the highway, there are normally paved off-ramps with helpful signs every few miles. You’ve probably also seen mad-made dirt paths where enough people have driven through the median to get off the highway because of frustration and impatience. There’s a good lesson for component libraries here:
If you don’t provide helpful off-ramps, people will make their own.
Handling edge cases
Shared components should be built to accommodate normal use cases and occasional edge cases. Some maintainers make the mistake of wanting to cover every edge case, but this creates several problems:
- Components become rigid and brittle as they support more cases
- Components become more challenging to maintain and update
- Component APIs become more opaque to users
Put simply, too many off-ramps create chaotic driving conditions and only increase maintenance costs. But there will always be edge cases we don’t anticipate, and people will always attempt to make their own off-ramps. This isn’t because they are poor drivers or have malicious intent. The road was not designed to accommodate their situation.
How can we design a solution to handle these unanticipated cases?
Components should be built in a composable way. By that I mean, larger, more complex components should be built using smaller, simple components. And those larger components should export those sub-components. This will allow users to drop a level below the higher abstraction and compose the sub-components in a way to meet their use case. This is the preferred solution as it allows users to resolve their issue in a way that is still consistent with the library. For example, let’s say my default Accordion component doesn’t quite fit my particular use case (I need an icon by the Accordion’s title). Because it’s composed of sub-components, I can do this:
We should create a clear channel of communication where maintainers can help users move forward with their issue. If a solution is not clear for a user to solve themselves, they should reach out to the maintainers. This process will be discussed further in another post, but the purpose of this communication channel is triaging the problem and help the user move ahead quickly. We might have to make a sub-optimal off-road, but we’ll do it together and document it.