Reusable UI Components Anti-Pattern, API Inheritance
Cory Rylan
- 3 minutes
When designing and building reusable UI Components, certain API choices can cause unexpected problems down the road as components mature. In this post, we are going to cover one of those anti-patterns, API Inheritance.
Let's start with our use case story, a simple button. We build out our reusable button and ship it to production without issue.
<ui-button>
menu
</ui-button>
A few weeks later, a new feature request comes in that wants buttons to have an icon in the button for certain use cases. We have a ui-icon
component built, so we add it to the button component and add a new API to render our button with and designated icon. We keep it simple and terse by adding an icon property/attribute to render the icon.
<ui-button icon-name="menu">menu</ui-button>
The button is shiped to production and everything works out as expected. A few weeks later, we get a new request. In certain cases, the icon in the button should change between a solid or outline style. So we add a new endpoint to customize our icon within the button.
<ui-button icon-name="menu" icon-solid>menu</ui-button>
But before we get to ship our feature, another request has come in. The optional icon we added in the button actually needs to be at the start, before the text within the button.
<ui-button icon-name="menu" icon-align="start" icon-solid>
menu
</ui-button>
Encapsulating child components and exposing their APIs to the host component might seem appealing for the sake of terse code. However, over time, as the icon component's API grows, the host component's API (e.g., the button API) may experience pressure to absorb and expose additional API endpoints for the icon. This pressure, known as "API Inheritance," can create tightly coupled and non-explicit APIs that only exist for specific component combinations. Consequently, the API becomes more complex and verbose as more "escape hatches" are required.
Introducing "escape hatch" APIs like positioning in our button can complicate things even further for use cases of internationalization where reading order and elements are reversed for right-to-left languages.
Let's look at an alternative composition-based approach with the same requirements as above.
<ui-button>
menu <ui-icon name="menu" solid></ui-icon>
</ui-button>
Rather than having the button expose an icon, we can use composition to render the icon within the button. In Web Components, this can be done with the slot
APIs, Angular with ng-content
, and React with the children
prop.
By using composition, we prevent the button from inheriting the API of the icon. As the icon API grows, the button remains stable. This keeps the component APIs distinct, with one API to use per component. Composition avoids creating a third meta API that acts as a glue between coupled component APIs.
<!-- API Inheritance -->
<ui-button icon-name="menu" icon-align="start" icon-solid>
menu
</ui-button>
<!-- API Composition -->
<ui-button>
menu <ui-icon name="menu" solid></ui-icon>
</ui-button>
In our example, API inheritance results in an API with 77 characters, while composition produces an API with only 67 characters. API inheritance, although initially tempting, can lead to increasingly complex APIs over time. By leveraging composition, we can avoid supporting tightly coupled APIs and maintain a smaller, easier-to-learn supported API surface area in the long run.