Nick Lemmon

Avoid stateful components in design systems

November 5, 2025

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.

Nick Lemmon

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.