Detail chart · Frontend

State Management Patterns

Pattern ◆◆◇◇◇

Manage application state through well-defined flows — local, shared, and server state each handled at the right layer.

Summary

State management is the discipline of deciding where state lives, who can change it, and how changes propagate through the UI. The central insight is that different state has different scope: local UI state belongs in a component; shared domain state belongs in a store; server state belongs in a cache. Mixing these categories in a single global store creates accidental coupling and makes components hard to reason about in isolation.

Problem

As applications grow, components need to react to the same data. The naive solution — lifting state to a common ancestor — creates prop drilling: data and callbacks thread through layers of components that neither read nor care about them.

Solution

Categorise state by scope and apply the appropriate mechanism to each category.

Local UI State — useState / useReducer

State that only one component cares about belongs in that component. Keep it local and co-located.

// ✅ Local state — no other component needs to know this modal is open
function FilterPanel() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Filters</button>
      {isOpen && <FilterDialog onClose={() => setIsOpen(false)} />}
    </>
  );
}

Use useReducer when local transitions are complex or follow a clear command vocabulary:

type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };

function counterReducer(state: number, action: Action): number {
  switch (action.type) {
    case 'increment': return state + 1;
    case 'decrement': return Math.max(0, state - 1);
    case 'reset':     return 0;
  }
}

Shared Client State — Context + Zustand

For state shared across multiple components, choose the mechanism based on update frequency:

React Context is appropriate for low-frequency state — theme, locale, current user identity — that rarely changes after mount. Frequent updates through context re-render all consumers.

const ThemeContext = createContext<'light' | 'dark'>('dark');

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme] = useState<'light' | 'dark'>('dark');
  return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}

A lightweight store (Zustand, Jotai, Signals) is appropriate for high-frequency or granular state where you need selective subscriptions — only the components that subscribe to a specific slice re-render.

// Zustand store — fine-grained subscriptions prevent cascade re-renders
const useFilterStore = create<FilterState>((set) => ({
  selectedTags: [],
  sortOrder: 'asc',
  addTag: (tag) => set((s) => ({ selectedTags: [...s.selectedTags, tag] })),
  removeTag: (tag) => set((s) => ({
    selectedTags: s.selectedTags.filter((t) => t !== tag),
  })),
  setSortOrder: (order) => set({ sortOrder: order }),
}));

// Only re-renders when selectedTags changes — sortOrder changes are ignored
function TagList() {
  const tags = useFilterStore((s) => s.selectedTags);
  return <ul>{tags.map((t) => <li key={t}>{t}</li>)}</ul>;
}

Server State — Query Caches (TanStack Query / SWR)

API data is not application state. It is a cache of remote truth. Treat it separately: use a query library that handles loading, error, stale-while-revalidate, and background refresh automatically.

// ✅ Server state — declarative fetching with automatic caching and invalidation
function PatternList({ domain }: { domain: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['patterns', domain],
    queryFn: () => fetchPatterns(domain),
    staleTime: 5 * 60 * 1000, // treat as fresh for 5 minutes
  });

  if (isLoading) return <LoadingShell />;
  if (error)     return <ErrorBoundary error={error} />;

  return <ul>{data.map((p) => <PatternCard key={p.id} pattern={p} />)}</ul>;
}

Unidirectional Data Flow

All of these mechanisms share a structural invariant: data flows down, events flow up. Components receive state as props or via subscriptions; they emit events (callbacks, store actions) to request changes. They never mutate shared state directly.

Store / Context
     │  (state flows down)
     ▼
  Component
     │  (events flow up via actions/callbacks)
     ▼
Store / Context

When to Use

Avoid when:

Trade-offs

BenefitCost
Each state category uses the right primitiveDevelopers must learn which mechanism to use and when
Granular subscriptions limit re-render surface areaMultiple state libraries in one project can be confusing
Server state is decoupled from client state, with automatic cache managementQuery library setup and tuning (staleTime, gcTime) adds initial overhead
Unidirectional flow makes debugging straightforwardStores and query caches are harder to unit-test than pure local state

Live Playground

Experiment with the pattern below. The example demonstrates the trade-off: lifting state to a parent solves prop drilling but causes cascading re-renders. Fine-grained subscriptions (Zustand) prevent unnecessary re-renders by letting components subscribe only to the state they need. Edit the controls or uncomment different implementations to observe the re-render counts.