State Management Patterns: A Complete Guide

State Management Patterns: A Complete Guide

“Where should this state live?” is the question that quietly shapes a codebase. Answer it consistently and features stay cheap; answer it ad hoc and every change becomes an archaeology project. This guide walks the spectrum from local to global.

The spectrum

State management is not one decision but four, made per piece of state:

  1. Local state — owned by one component.
  2. Lifted state — shared by siblings via the nearest common parent.
  3. Ambient state — provided down a subtree (theme, session).
  4. Global state — an external store any component can subscribe to.

Move state up the list only when a real consumer forces you to.

Local first

Most state never needs to leave its component:

function SearchBox() {
  const [query, setQuery] = useLocalState('');
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

Local state is trivially testable and deleted for free when the component goes.

Lifting without tears

When two siblings need the same value, lift it to their parent and pass it down. The pain starts when the parent becomes a switchboard of a dozen values. That is the signal to group related state into a single object with a reducer:

const [state, dispatch] = useReducerState(cartReducer, { items: [] });

dispatch({ type: 'add', item });

A reducer centralizes the transitions, which is where bugs actually live.

Global stores

External stores earn their place when state outlives any subtree: the current user, feature flags, a shopping cart. Modern minimal stores are a few lines to define:

const useCartStore = createStore((set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
  clear: () => set({ items: [] }),
}));

Components subscribe to slices, so unrelated updates do not re-render them. Compared with the classic action/dispatcher architecture, there is dramatically less ceremony — but also fewer guardrails, so keep store logic pure and side-effect free.

Server state is different

Data fetched from an API is not really your state — it is a cache of someone else’s. Treat it with cache semantics (staleness, revalidation, optimistic updates) instead of store semantics:

const { data, isStale, refresh } = useRemote('/api/orders');

Mixing server cache into a global store is the single most common source of stale-data bugs.

Choosing quickly

Question If yes
Does one component own it? Local state
Do siblings share it? Lift it
Is it ambient (theme, session)? Context
Does it outlive the subtree? Global store
Does a server own it? Cache, not store

Conclusion

There is no winning library, only a well-matched pattern per piece of state. Default to local, lift deliberately, keep server data in a cache, and reserve the global store for the handful of values that genuinely belong to the whole application.