Design Engineering
Build

State Management

Choose the right state layer for each type of state. Most state should live closer to the server than you think.

State management decides where state lives and how it flows. The most common mistake: centralising state that should stay local, and localising state that should be URL or server state. Start with the least powerful option.

Categories of State

CategoryExamplesPreferred storage
UI stateModal open/closed, accordion expanded, tooltip visibleuseState
Form stateField values, validation errors (in-progress input)react-hook-form or useState
URL stateFilters, selected tab, pagination, search queryURL search params
Server stateUser profile, project list, API responsesSWR or TanStack Query
Global UI stateCurrent user, theme, notification queue, feature flagsReact Context or Zustand

In a well-architected app, most state is URL or server state. Reaching for a global store for "data from the server" usually means a missing caching layer.

Local Component State

useState for state owned by a single component. Lift up only when siblings genuinely need to share.

Local state — disclosure component
function Disclosure({ title, children }) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button
        onClick={() => setIsOpen(!isOpen)}
        aria-expanded={isOpen}
        aria-controls="disclosure-content"
      >
        {title}
      </button>
      <div id="disclosure-content" hidden={!isOpen}>
        {children}
      </div>
    </div>
  )
}

URL State

State users might share, bookmark, or return to — filters, sort order, pagination, tabs, search queries — belongs in the URL. It requires no library and survives refreshes.

Server State

Remote data that must be fetched, cached, and synced. Avoid useEffect + useState — it's error-prone boilerplate. Use SWR or TanStack Query.

Global Client State

Reserve for genuinely application-wide state that can't live in URL or derive from server. Legitimate uses: current user, active theme, notification queue.

Zustand — minimal global store
import { create } from "zustand"
import { persist } from "zustand/middleware"

interface NotificationStore {
  notifications: Notification[]
  add: (notification: Omit<Notification, "id">) => void
  dismiss: (id: string) => void
}

export const useNotifications = create<NotificationStore>((set) => ({
  notifications: [],
  add: (notification) =>
    set((state) => ({
      notifications: [...state.notifications, { ...notification, id: crypto.randomUUID() }],
    })),
  dismiss: (id) =>
    set((state) => ({
      notifications: state.notifications.filter((n) => n.id !== id),
    })),
}))

Tool Selection Guide

ToolUse forDo not use for
useStateComponent-local UI stateData shared across pages
useReducerComplex local state with multiple transitionsServer data, URL state
URL search paramsFilters, pagination, tabs, searchSensitive data, large objects
React ContextLow-frequency global state: theme, current user, localeHigh-frequency updates — causes re-renders
SWRServer data: fetching, caching, mutations, pollingNon-remote state
ZustandClient global state with complex update logicSimple state that fits in Context
JotaiFine-grained reactive state where Context would over-renderReplacing server state

On this page