Topic #23Core15 min read

State Management Overview

A decision framework for where state should live: local vs lifted vs derived vs Context vs global store vs server state — plus the categories of client-state libraries (Redux Toolkit, Zustand, Jotai, Recoil) and why server state is a separate discipline handled by TanStack Query / SWR.

#react#state-management#context#redux#zustand#server-state#tanstack-query#derived-state

State management is really a placement problem. The hard part of state is rarely which library you pick — it is deciding where each piece of state should live and how far it should be shared. Put state too low and siblings cannot see it; put it too high and unrelated components re-render and the app becomes hard to reason about. The skill is matching each piece of state to the smallest scope that satisfies its consumers, and only escalating when a real requirement forces you to.

The escalation ladder. There is a natural order to reach for: (1) local state with useState/useReducer inside the one component that owns it; (2) lifted state moved up to the closest common parent when two or three siblings must share it; (3) derived state computed from existing state during render (do not store what you can calculate); (4) Context to distribute a low-frequency value across a distant subtree; (5) a global client store (Redux Toolkit, Zustand, Jotai) when many components across the app read and write complex, frequently-changing state; and (6) server state for anything that actually lives on the backend. Start at the top and move down only under pressure.

Local state is the default and it is underrated. The vast majority of state — input values, toggles, hover/open flags, the active tab — belongs to a single component and should stay in useState there. Local state is the simplest to reason about, has no shared blast radius, and is trivially removed when the component unmounts. A frequent mistake is promoting local state to global 'just in case,' which adds coupling and re-renders for no benefit.

Lifting state up — and knowing when to stop. When siblings must stay in sync (a filter input and the list it filters), move the state to their nearest common parent and pass it down. This is the canonical React pattern. But lifting has a cost: the parent re-renders and props thread down. If you find yourself lifting the same state through many levels, that is the signal to switch to Context (for distribution) or a store (for management), not to keep drilling.

Derived state: compute, do not duplicate. A large class of 'state bugs' come from storing something that could be derived. If you have items and a filter, the filtered list is derived — compute it during render (memoised with useMemo if expensive) rather than storing a second filteredItems array you must keep in sync. The rule: store the minimal source of truth; derive everything else. Duplicated state that can drift is a bug waiting to happen.

Context is distribution, not management. Context solves prop-drilling for low-frequency global values (auth, theme, locale) but re-renders every consumer when its value changes and has no selectors. It is the right choice when the value is stable and read widely. It is the wrong choice for high-frequency updates or large state graphs with many independent slices — that is where a real store with selector-based subscriptions earns its keep.

Global client-state libraries — the landscape. When you genuinely need app-wide client state, the main options differ in philosophy. Redux Toolkit is a single centralised store with actions/reducers, excellent devtools and time-travel, and a predictable one-way data flow — great for large teams and complex, auditable state. Zustand is a tiny hook-based store with minimal boilerplate and built-in selector subscriptions. Jotai/Recoil are atom-based: state is split into small independent atoms and components subscribe only to the atoms they use, which minimises re-renders. All three (unlike raw Context) offer fine-grained subscriptions so a component re-renders only when the specific data it reads changes.

Fine-grained subscriptions are the real advantage of a store over Context. The reason a store beats 'Context + useReducer' at scale is the selector: useSelector(state => state.cart.total) subscribes the component to just cart.total, so it re-renders only when that number changes — not when any unrelated slice updates. Context cannot do this; it re-renders all consumers on any value change. When re-render performance across a large shared state graph matters, that selector capability is decisive.

Server state is a different discipline. Data that originates on a backend — a list of users fetched from an API — is not really your state; it is a cache of state that lives on the server. It needs concerns UI state never does: caching, deduping identical requests, background refetching, staleness/expiry, retry, pagination, and revalidation after mutations. Hand-rolling all of that inside Redux or Context means reimplementing a cache poorly. This is why TanStack Query and SWR exist: they own the server-cache lifecycle so your client store only holds true client state.

The classic anti-pattern: server data dumped into Redux. A very common mistake is fetching in a thunk and storing the response in Redux, then manually handling loading flags, refetch, and invalidation everywhere. It works, but you end up rebuilding TanStack Query by hand and the store fills with data that is really just a cache. The modern split is: server state → TanStack Query/SWR; client state → Redux Toolkit/Zustand/Context/local. RTK even ships RTK Query specifically to handle server state within the Redux ecosystem.

A practical decision checklist. Ask, in order: Does only one component use it? → local. Do a couple of siblings share it? → lift. Can it be computed from other state? → derive, do not store. Is it a stable global value read widely? → Context. Is it complex client state with many updaters or needing devtools/time-travel? → Redux Toolkit (or Zustand/Jotai). Does it come from a server and need caching/refetch? → TanStack Query/SWR. Most answers land in the first three rungs.

The mental model (memorise this). State management is choosing the smallest scope that works and escalating only under pressure: local → lifted → derived → Context → global store → server state. Context distributes low-frequency values but re-renders all consumers; stores add selector-based subscriptions for fine-grained updates; and server data is a separate cache handled by TanStack Query or SWR, never hand-rolled in a client store.

Backend Analogy

Think of state placement like choosing the scope of a variable or bean in a Spring service. Local useState is a method-local variable — private, short-lived, no shared contention. Lifted state is a field on the enclosing class shared by a couple of methods. Context is a request- or session-scoped bean injected wherever it is needed. A global store (Redux/Zustand) is a singleton application-scoped bean with well-defined mutators and an audit log (devtools/time-travel). And server state via TanStack Query is your caching layer — the second-level Hibernate cache or a Caffeine/Redis cache in front of the database: it dedupes reads, expires stale entries, and revalidates, so you never confuse the cache with the source of truth. Dumping API data into Redux is like caching rows in a plain HashMap and reinventing eviction and refresh yourself instead of using the cache abstraction built for it.

Key Insights
  • State management is mostly a placement problem: put each piece of state in the smallest scope that satisfies its consumers, and escalate only when a real need forces it.
  • Escalation ladder: local (useState) → lifted to a common parent → derived (compute, do not store) → Context → global store → server state.
  • Local state is the correct default for most UI state; do not promote it to global 'just in case.'
  • Derive anything you can compute from existing state instead of storing a duplicate that can drift out of sync.
  • Context distributes low-frequency values but re-renders all consumers and has no selectors; it is distribution, not management.
  • A store's decisive edge over Context at scale is selector-based subscriptions: components re-render only when the specific slice they read changes.
  • Client-store options differ in philosophy: Redux Toolkit (centralised, devtools/time-travel), Zustand (tiny hook store), Jotai/Recoil (atoms with fine-grained subscriptions).
  • Server state (API data) needs caching, deduping, refetch, staleness, and revalidation — a separate discipline from UI state.
  • Use TanStack Query or SWR for server state; do not hand-roll a cache inside Redux or Context. RTK Query fills this role inside the Redux ecosystem.
  • Quick checklist: one component → local; a few siblings → lift; computable → derive; stable global → Context; complex client → store; from a server → TanStack Query/SWR.

Worked Code

The escalation ladder as a comparison table
TypeScript
/*
| Layer        | Tool                      | Use it when                                            |
| ------------ | ------------------------- | ------------------------------------------------------ |
| Local state  | useState / useReducer     | Inputs, toggles, tabs — owned by one component         |
| Lifted state | useState in parent        | 2–3 siblings must stay in sync                         |
| Derived      | compute in render/useMemo | The value is calculable from existing state            |
| Context      | createContext/useContext  | Stable global value read widely (auth, theme, locale)  |
| Global store | Redux Toolkit / Zustand   | Complex client state, many updaters, devtools needed   |
| Server state | TanStack Query / SWR      | Data from an API needing caching, refetch, revalidate  |
*/
export {};
Derived state: compute, do not duplicate
TSX
import { useState, useMemo } from 'react';

type Item = { id: number; name: string; price: number };

function Cart({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('');

  // BAD would be: const [filtered, setFiltered] = useState(items);
  // then trying to keep 'filtered' in sync on every change — it WILL drift.

  // GOOD: derive it during render. 'items' + 'query' are the source of truth.
  const filtered = useMemo(
    () => items.filter(i => i.name.toLowerCase().includes(query.toLowerCase())),
    [items, query],
  );

  // total is also derived — never store it.
  const total = filtered.reduce((sum, i) => sum + i.price, 0);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <p>{filtered.length} items, total ${total}</p>
    </div>
  );
}
Server state with TanStack Query — no hand-rolled cache
TSX
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// The library owns caching, deduping, background refetch, and staleness —
// you do NOT store this in Redux or Context.
function Users() {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json()),
    staleTime: 60_000, // treat data as fresh for 60s before refetching
  });

  if (isLoading) return <p>Loading…</p>;
  if (isError)   return <p>Failed to load</p>;
  return <ul>{data.map((u: any) => <li key={u.id}>{u.name}</li>)}</ul>;
}

function AddUser() {
  const qc = useQueryClient();
  const mutation = useMutation({
    mutationFn: (name: string) =>
      fetch('/api/users', { method: 'POST', body: JSON.stringify({ name }) }),
    // After a write, invalidate the cache so the list revalidates.
    onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
  });
  return <button onClick={() => mutation.mutate('Ada')}>Add</button>;
}
Zustand — a tiny client store with selector subscriptions
TypeScript
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  reset: () => void;
}

// A store is just a hook. No Provider, no boilerplate.
const useCounter = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
  reset: () => set({ count: 0 }),
}));

// Selector subscription: this component re-renders ONLY when count changes,
// not when unrelated store fields change — the key advantage over Context.
function useCount() {
  return useCounter((s) => s.count);
}
export { useCounter, useCount };

Interview-Ready Q&A

Default to local useState in the component that owns it. If two or three siblings need it, lift it to their nearest common parent. If it can be computed from existing state, derive it during render instead of storing a duplicate. If many distant components need a stable global value like auth or theme, use Context. If you have complex client state with many updaters or need devtools/time-travel, use a store like Redux Toolkit or Zustand. If the data comes from an API, treat it as server state and use TanStack Query or SWR. Start at the top of that ladder and escalate only under real pressure.

Things to Remember
  • 1State placement is the real skill: smallest scope that works, escalate only under pressure.
  • 2Ladder: local → lifted → derived → Context → global store → server state.
  • 3Local useState is the correct default for most UI state.
  • 4Derive computable values in render; never store a duplicate that can drift.
  • 5Context distributes low-frequency global values but re-renders all consumers and has no selectors.
  • 6Stores (Redux Toolkit, Zustand, Jotai) add selector subscriptions for fine-grained re-renders.
  • 7Redux Toolkit = centralised + devtools; Zustand = tiny hook store; Jotai/Recoil = atoms.
  • 8Server state (API data) needs caching/refetch/staleness — a distinct discipline.
  • 9Use TanStack Query / SWR (or RTK Query) for server state; do not hand-roll it in a store.
  • 10Checklist: one component → local; few siblings → lift; computable → derive; stable global → Context; complex client → store; from a server → Query.

References & Further Reading