Avoid stateful components in design systems

Design system components should be thought of as akin to Lego bricks — composable pieces of a user interface that empower engineers working on applications to build consistent and branded user experiences. At times, design systems drift towards larger, stateful components to help with code sharing and re-usability. In theory, this makes sense! In practice, however, design system teams rarely have the business context required to successfully engineer components that can handle a wide enough variety of use cases.
How can a design systems team identify when a component is veering into this territory, and becoming less like a Lego brick and more like a glued-together Lego kit? It all starts with internal component state.
Stateless components
Design system components tend to expose customization options via component props and event handlers. Consider a simple button component:
function Button({
variant,
children,
onClick,
}: {
variant: 'primary' | 'secondary' | 'destructive'
children: React.ReactNode
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
}) {
return <button onClick={onClick}>{children}</button>
}
The button accepts three props (variant, children, and onClick). The
button has no opinions about what happens when it is clicked but allows
application engineers to handle those decisions in their application code.
Stateful components
The Button we defined as part of our design system, maintains no internal
state and is updated entirely from the outside.
Next, our team wants to tackle a SortButton component that can be used in
sorting scenarios for other UI elements like data tables. According to the
provided UX specs, when clicked, the sorting state for the button should toggle
between "none", "ascending" and "descending".
import { type ComponentProps, useState } from 'react'
import { Button } from '@com/ui'
function SortButton({ children, onClick, ...props }: ComponentProps<typeof Button>) {
// `useState` here allows for reactive updates from inside the component as opposed to externally
const [sort, setSort] = useState<'ascending' | 'descending' | 'none'>('none')
/** Click handler to manage sorting state */
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
const getNextSort = () => {
if (sort === "none") return "ascending"
if (sort === "ascending") return "descending"
if (sort === "descending") return "none"
return "none"
}
setSort(getNextSort())
/** Any passed-in onClick handler fires last */
onClick(e)
}
return (
<Button {...props} onClick={handleClick}>
{children}
{sort === "none" ? ↕️ : null}
{sort === "ascending" ? ⬆️ : null}
{sort === "descending" ? ⬇️ : null}
</Button>
)
}
Now our SortButton updates the UI based on the next possible sort state.
...Three weeks later
Our design system is a raging success, and application engineers are using our
stateless Button and stateful SortButton components. The SortButton works
great for sorting tables and other organized data UI. What could possibly go
wrong?
URL state
For many sortable/filterable user interfaces, the ability to determine state
from the URL is a common pattern using
query string parameters.
Our application wants to update a SortButton instance according to a query
string in their application URL with three different possible states:
https://app.mycom.com
https://app.mycom.com?sort=ascending
https://app.mycom.com?sort=descending
Unfortunately, our current SortButton is completely unable to handle this
feature request! The design system team never considered that something other
than a user click could cause the state of the button to update. Because the
state of our component is controlled by an internal useState hook, our options
for updating this component (particularly in a non-breaking fashion) are limited
or at the very least, highly complex.
Making the best of a bad situation
We can "fix" this problem by allowing external state to initialize the
SortButton component state:
import { type ComponentProps, useState } from 'react'
import { Button } from '@com/ui'
type SortDir = 'ascending' | 'descending' | 'none'
function SortButton({ children, onClick, sort = 'none', ...props }: ComponentProps<typeof Button> & { sort: SortDir }) {
// `useState` here allows for reactive updates from inside the component as opposed to externally
const [sortState, setSortState] = useState<SortDir>(sort)
/** Click handler to manage sorting state */
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
/** Determine the next sort state according to the previous sort state */
const getNextSort = () => {
if (sortState === "none") return "ascending"
if (sortState === "ascending") return "descending"
if (sortState === "descending") return "none"
return "none"
}
// Internal state handling
setSortState(getNextSort())
// Any passed-in onClick handler fires last
onClick(e)
}
return (
<Button {...props} onClick={handleClick}>
{children}
{sortState === "none" ? ↕️ : null}
{sortState === "ascending" ? ⬆️ : null}
{sortState === "descending" ? ⬇️ : null}
</Button>
)
}
Now, the component can at least initially be updated externally with the URL state like so:
const url = new URL(window.location.href) // "https://app.mycom.com?sort=ascending"
const sort = url.searchParams.get('sort') // "ascending"
function MyApp() {
return <SortButton sort={sort}>Sort items</SortButton>
}
Note that this "fix" only works when the sort value is set before the component first renders. If the URL changes after the component is already mounted (for example, when navigating with client-side routing or when users manually edit the URL), the component's internal state won't update to reflect the latest URL.
Stateless from the beginning
The SortButton component could have been implemented successfully, taking in
the URL search updating as well as any other source of state in the future by
building stateless components instead of stateful components. In fact,
by limiting design system components to only expose props and event handlers,
application engineers can handle state management while the complexity of design
system source code drops tremendously.
import { type ComponentProps } from 'react'
import { Button } from '@com/ui'
type SortDir = 'ascending' | 'descending' | 'none'
function SortButton({ children, sort = 'none', ...props }: ComponentProps<typeof Button> & { sort: SortDir }) {
return (
<Button {...props}>
{children}
{sort === "none" ? ↕️ : null}
{sort === "ascending" ? ⬆️ : null}
{sort === "descending" ? ⬇️ : null}
</Button>
)
}
The component definition itself is nearly half as long, and can account for any future external state that an application engineer might use to handle sorting.
Then, an application engineer can control the state of the button, keeping it in
sync with any required business logic or source of UI state. In this example,
the MyDataTable reads from and updates the application URL in response to user
clicks of the SortButton:
function MyDataTable() {
const [searchParams, setSearchParams] = useSearchParams()
const sort = (searchParams.get('sort') as SortDir) || 'none'
const handleSortClick = () => {
/** Determine the next sort state according to the previous sort state */
const getNextSort = () => {
if (sort === 'none') return 'ascending'
if (sort === 'ascending') return 'descending'
if (sort === 'descending') return 'none'
return 'none'
}
setSearchParams({ sort: getNextSort() })
}
return (
<SortButton sort={sort} onClick={handleSortClick}>
Sort items
</SortButton>
)
}
Conclusion
Design system components are Lego bricks from which to build larger user interfaces — they should not ship glued together Lego kits with their own internal state! In general, avoid building a design system component that manages its own internal state. Keep components simple and stateless, by only providing props and event handlers.

About Me
I’m a frontend engineering manager working for Truist Financial currently based in in Cary, North Carolina.
I’m driven to spearhead, design, and build accessible design systems and web applications with a great underlying developer experience in mind.