The case for headless APIs in design systems
Carta’s UI design system, ink, gives our designers and engineers the building blocks they need to make complex information easy to digest. When a product team requests visual changes to a component to support a new user interface pattern, the ink team traditionally adds a new prop that allows engineers to opt into that customization. Over time, the accumulation of props becomes a maintainability concern, particularly for components with many visual variants. Here’s how we leveraged the headless API design pattern to stop accumulating tech debt in one of our most important and complicated components, something we call Ledger.
What is a headless API?
The headless API design pattern separates the logic of a component from its visual representation. It’s a user interface pattern that (ironically enough) doesn’t actually have a user interface. Instead, a headless API encapsulates dynamic functionality into custom hooks that expose values and callbacks. From there, the engineer is able to supply those values and callbacks to static visual elements.
Suppose you want to create a button that displays a click count. Each time you click the button, the count is incremented by one.
To create a headless API, first you would create a custom hook that encapsulates the logic and maps each variable to a prop that is consumed by the button component:
const useClickCountButton = () => {
const [count, setCount] = React.useState(0);
const incrementCount = setCount(count + 1);
return { children: count, onClick: () => incrementCount };
};
Then, you can unpack the values created by the hook into a component that controls the way the visuals are rendered onto the DOM:
const CounterButton = () => {
const countButton = useClickCountButton();
return <Ink.Button {...countButton} />;
};
This pattern works best when the behavioral logic is complex, so our CounterButton example likely wouldn’t be a real candidate for a headless API. However, it clearly demonstrates the way headless APIs separate behavioral logic from display logic, as the visual representation is entirely controlled by the consumer.
Separating behavior from visuals makes it easier to change the visual representation in response to design requirements. Likewise, engineers can update behavioral logic without any changes to the consumers. They can extend or modify the props, getters, and setters returned by the custom hook to meet unique business needs. Moreover, visual changes no longer require involvement from the team maintaining the design system — engineers are empowered to control their own visuals, rather than relying on the design system to provide a prop that controls their desired behavior.
ink isn’t the first library to embrace the power of headless APIs. Downshift is an excellent project which offers autocomplete/dropdown/select functionality, without any user interface whatsoever. By using a headless API that isn’t tied to a specific UI, engineers can be sure that Downshift won’t clash with their unique design requirements.
Building the useLedger headless API
The Ledger component of ink is an interactive table that handles API requests, searching, sorting, filtering, and pagination. It is ideal for displaying data about equity, fund investments, board memberships, and more. As of today, there are 164 instances of Ledgers across the Carta ecosystem.
Unfortunately, there are multiple issues at play with maintaining Ledger. Controlling the visual rendering of the component through props comes with a high maintenance cost, which can result in unnecessary complications and potential bugs. Because Ledger is such a popular component, we’ve had to add many props over the years which control minor visual details of the way the component renders, resulting in an overly-complex API. The addition of these props has created a fragile underlying code structure, which makes it difficult to update both visual and behavioral logic. And perhaps worst of all, the API is a black box to the engineers who use ink, who have no insight into how the props impact the way the component renders.
As a way to address these issues, we decided to build our first-ever headless API as an alternative to the Ledger component. The useLedger API is a custom hook which takes in a URL and returns a set of prop getters and setters, which the engineer can spread into other ink components, in order to create a functioning ledger — without the Ledger component. With useLedger, engineers have finely detailed control over the way their Ledger renders on the page, without needing to rely on render props.
Let’s look at an example in order to understand the benefits of useLedger. This is an example of a ledger:
To build this ledger with the Ledger component, you would use the following code:
<Ink.Ledger
id="ledger-example"
columns={[
{ label: 'Name', key: 'name', header: { sortable: true } },
{ label: 'Security', key: 'security', header: { sortable: true } },
{ label: 'Email', key: 'email' },
{ label: 'Status', key: 'status' },
]}
ribbon={{
key: 'status',
definitions: [
{ value: 'approved', text: 'Approved', color: 'green' },
{ value: 'waiting', text: 'Waiting', color: 'orange' },
],
}}
/>
As you can see, there are several props in play here that control the way visuals are rendered on the DOM. The columns prop controls the names of the columns, and determines whether or not they are sortable. The ribbon prop dictates which rows render with Ribbons on the upper left-hand side of the first table cell on each row.
In contrast, here’s how you would code the same UI with the useLedger headless API:
const {
data,
getSearchProps,
selected,
selectableProps,
getSortingProps
} = Ink.useLedger({
url: API_URL,
});
return (
<>
<Ink.NewInput {...getSearchProps()} />
<Ink.NewTable id=”useledger-example”>
<Ink.NewTable.Head>
<Ink.NewTable.Row>
<Ink.NewTable.HeadCell>
<Ink.NewCheckbox id="select-all" checked={selected.allRows} onChange={selectableProps.toggleAllRows} />
</Ink.NewTable.HeadCell>
<Ink.NewTable.HeadCell>
<Ink.NewTable.Pin {...getSortingProps().sortByKey('name')}>Name</Ink.NewTable.Pin>
</Ink.NewTable.HeadCell>
<Ink.NewTable.HeadCell>
<Ink.NewTable.Pin {...getSortingProps().sortByKey('security')}>Security</Ink.NewTable.Pin>
</Ink.NewTable.HeadCell>
<Ink.NewTable.HeadCell>
Email
</Ink.NewTable.HeadCell>
<Ink.NewTable.HeadCell>
Status
</Ink.NewTable.HeadCell>
</Ink.NewTable.Row>
</Ink.NewTable.Head>
<Ink.NewTable.Body>
{data.map(d => (
<Ink.NewTable.Row key={d.email} selected={selected.rows[d.email]}>
<Ink.NewTable.Cell preset="checkbox">
<Ink.NewCheckbox id={d.email} onChange={selectableProps.toggleRow} checked={selected.rows[d.email]} />
</Ink.NewTable.Cell>
<Ink.NewTable.Cell>
<Ink.NewTable.Ribbon color={getRibbonColor(d.status)} text={d.status} />
{d.name}
</Ink.NewTable.Cell>
<Ink.NewTable.Cell>{d.security}</Ink.NewTable.Cell>
<Ink.NewTable.Cell>{d.email}</Ink.NewTable.Cell>
<Ink.NewTable.Cell>{d.status}</Ink.NewTable.Cell>
</Ink.NewTable.Row>
))}
</Ink.NewTable.Body>
</Ink.NewTable>
</>
);
While there’s more code involved in the headless API approach, there are no magic props controlling the way the ledger is rendered onto the page. Rather than passing in a columns prop which renders an array into columns behind the scenes, useLedger allows the engineer to explicitly control their columns and the way they behave. Instead of passing sortable: true into a configuration object, the engineer can pass through sorting props into the appropriate column headers. Likewise, instead of controlling Ribbons with a ribbon prop, the engineer can add the Ribbon component to the cell.
So why should you build a headless API when you can just use render props? While render props work in certain circumstances, they are more of a way to graft nodes into a specific slot than a way to let the engineer control composition. Headless APIs are a far less restrictive way to specify which components render onto the DOM.
Headless APIs: the future of design systems
While not appropriate for all components, headless APIs are a flexible, transparent alternative to large monolithic components which take in many props to control visual state. They’re easier to maintain, less error-prone, and fully extensible for the needs of varying customers.
useLedger exemplifies the way that design systems can — and should — evolve to use headless APIs. By providing both headless APIs and visual display components to ingest those APIs, design systems can give their users flexibility and open the door to creativity, while still enforcing maintainable code patterns.
We’ll continue to add headless APIs to ink and give our developers alternatives to its complex monolithic components, helping our colleagues fulfill the custom needs of our clients while maintaining Carta’s visual identity. If you’d like to help us build the next version of Carta, we’re hiring!