A shared component library provides a way to create reusable, consistently-styled user experiences across multiple applications. The decision to create such a library comes with several difficult options and tradeoffs. Here, I hope to share how a trio of Atomic Object teams tackled some of these questions and what worked best for us.
For context, we are working on several Typescript-React applications across multiple internal development teams. We also have a small team of designers that created a style guide and a library of common components. The design team was actively designing the components and UX a couple of steps ahead of development. Despite initial usage being largely internal, we have aimed to make the library usable by external client development teams sometime in the future. We’re retrofitting a couple of the apps to adopt the new style guide, and the rest we’re building from scratch with the new library.
Before starting development, we established a set of guiding principles for the library. Those principles are backward compatibility, presentational components, extensibility, and minimalism.
Our components are backward compatible across versions whenever possible. Sometimes this means slightly awkward deprecated prop types. One of our fears when starting this library was a situation where Version 3 might make some breaking changes to an existing component. Then, 4 adds a new component, so if a consumer on Version 2 wants to use the new component, they would also have to reconcile with the changes introduced in the upstream version. To avoid this issue, we have strictly followed the “major.minor.patch” versioning patterns of Semantic Versioning. Patch updates are small fixes to existing components, while minor updates typically do things like introduce new components. Major versions introduce breaking changes.
Components in our library intend to be purely presentational. This means no business logic appears in the implementation, and components are minimally stateful. This is accomplished by props representing all the data to be displayed. Additionally, actions performed by the components are passed in as callbacks. This principle provides maximal reusability and ease of maintenance.
The principle of minimalism states that components should be as slim as possible with no assumptions about where or how they’ll be used. Consuming applications can be responsible for the layout of components. The components should not provide their own outer padding or margin, for example. Additionally, implementations should be aware of required versus optional props and not force consumers to provide extraneous information.
In an effort to minimize future updates to implemented components, we want our components to be extensible. For low-level components like buttons and inputs, this looks like extending the respective HTML interface for such components. That way we don’t need to come back in to hook into underlying HTML attributes later on. For more complex components, extensibility means building out generic type-safe interfaces that cover all possible states as designed in the style guide.
One early tough question to answer was how the team would contribute code to the library. Some ideas we tossed around were a single team responsible for all contributions, completely decentralized contributions, and decentralized contributions with a “library lead” type position. We’ve waffled with this decision, and I think the best approach for any given team is highly context-driven.
Our most recent approach is to have each team able to contribute components and fixes in a decentralized way and each team nominates a per-sprint “library reviewer” to be responsible for reviewing pull requests from the other teams. This has allowed our teams to move fast and independently while still being able to have some input on library standards and component interfaces.
I think a single-team approach would work best when the design system is complete and well-defined prior to development. A decentralized approach only feels feasible for very small teams. At scale, it is difficult to maintain quality with many perspectives and considerations represented.
Distribution and Versioning
Another early question we explored was the best way to distribute and version our library. For distribution, we pretty quickly settled on an NPM package in a private registry. Other options we briefly considered were Git submodules and federated modules. Distributing via an NPM package has some drawbacks, but other approaches didn’t solve those problems without introducing other obstacles.
Then came the question of versioning our library. Do we want to version the library as a whole or allow individual components within the library to be versioned? We ultimately settled on versioning the library as a whole. Brad Frost has a wonderful blog post breaking down the pros and cons of both viewpoints that was helpful when considering our options.
One other important principle we’ve centered on regarding versioning is the idea that no single version is sacred. We intentionally started our versioning at 1.0.0 because the jump from 0.x.y to 1.0.0 is a scary step to take. Once you have that first major version, then it’s easy to continue bumping higher. Additionally, we don’t worry about keeping our versions low. I think versions increasing fast can scare some teams into thinking things are changing too fast. I figure if we bump a couple of major versions and a handful of minor versions in a day, that’s great. It means we got a lot done, not that there was too much churn.
I wish I had explored more of our options for how to style our components. We chose to use Sass modules because we were already widely using it within our consumer applications. There are many options available for distributable component styling like Material Design theming, countless CSS-in-JS libraries like styled-components, and other CSS preprocessors like Less. I have not researched these alternatives enough to know if they would have been better or worse experiences, but we’ve had several headaches with Sass modules (that’s a story for another post). Our current styling system solves most of the hard problems well enough. But, once you pick a route, it’s a big lift to migrate to something else, so be sure to do your research.
A very easy choice to make for our component library was to integrate with Storybook. Running Storybook locally offers developers a fast and easy way to iterate on their components in isolation. We also host a deployed version of our Storybook from the latest dev branch. This provides a place for designers to quickly review our implementations even prior to integrating the components into consumer applications. It also acts as a reference to the current state of the library. If a developer is looking to use a specific variant of a component, they can refer to the deployed Storybook instance to find the exact configuration they need.
Use a Component Library for a Better Development Experience
Building a component library has made developing consistent and beautiful applications a breeze. Making some of these decisions has been difficult, but using the resulting library improves the development experience overall.