State Management
State lives at many layers: local, global, server and URL. Learn when to use useState, Context, external stores and server-state libraries so data lives close to where it is consumed.
What is this?
State management is deciding where each piece of mutable data lives and how it flows to consumers. Local state lives in a component; lifted state moves up to a shared ancestor; global state lives outside React in a store or Context; server state — data mirrored from an API — is cached and synchronized by a query library. Each layer has a cost in complexity, so the art is choosing the smallest layer that works.
Why do you need it?
- Colocated state is simpler: components that own their data are easy to read and test.
- A single source of truth for shared data prevents duplicated state that drifts out of sync.
- External stores with selectors let only subscribed components re-render, avoiding tree-wide cascades.
- Server-state libraries handle caching, deduplication and invalidation that hand-rolled code rarely gets right.
- Explicit state architecture makes bugs reproducible and teams productive as an app grows.
Mental model
A shared whiteboard
A global store
Everyone writes to one whiteboard and reads the same values, so nobody works from a stale copy. But if only two people need the number, a sticky note (local state) is simpler than a whiteboard for the whole office.
How it works
- useState and useReducer keep state inside a component; useReducer centralizes update logic in a pure reducer.
- Context provides values down the tree, but every consumer re-renders when the provider value changes unless it is memoized.
- External stores (Zustand, Jotai, Redux) hold state outside React; components subscribe to slices via selector hooks.
- Query libraries cache server data, deduplicate concurrent requests, and expose invalidation for mutations.
- Derived data is computed during render rather than stored, so there is exactly one source of truth per fact.
Choosing where state lives.
Key commands
npm create vite@latest state-app -- --template react-tsScaffold a TypeScript React app.
npm install zustandAdd the Zustand store library.
npm install @tanstack/react-queryAdd a server-state cache and data-fetching library.
npm run devStart the dev server.
npm run buildType-check and build the production bundle.
Examples
A Zustand store with a selector
import { create } from "zustand"; interface CartState { items: string[]; add: (item: string) => void; remove: (item: string) => void;} export const useCart = create<CartState>((set) => ({ items: [], add: (item) => set((state) => ({ items: [...state.items, item] })), remove: (item) => set((state) => ({ items: state.items.filter((i) => i !== item) })),})); function CartCount() { const count = useCart((state) => state.items.length); return <span>{count} items</span>;}Zustand holds the store outside React. CartCount subscribes with a selector that returns only items.length, so it re-renders only when that value changes — other consumers are untouched. Actions update the store with immutable updates via set.
In production
- E-commerce carts, auth sessions and multi-step forms are modeled in external stores shared across pages.
- Dashboards use server-state libraries so every widget dedupes its API requests against the same cache.
- URL state encodes filters and pagination so users can share a bookmarked view.
- Large teams enforce architecture with stores that live outside components and are tested in isolation.
Best practices
- Start with the smallest layer: local state, then lift, then Context/store, then a query cache for server data.
- Store facts once and derive everything else during render to prevent conflicting duplicates.
- Subscribe with selectors and ensure selectors return primitives or memoized references to limit re-renders.
- Keep stores free of components: actions are plain functions you can unit-test without a renderer.
- Treat server data as cache: invalidate and refetch after mutations, never hand-sync it to a store.
Common mistakes
- Putting every value in a global store and re-rendering large subtrees on minor updates.
- Duplicating server data into a store and writing manual sync logic that drifts out of date.
- Storing derived values (a filtered list) as state and never updating it when the source changes.
- Using Context for high-frequency updates and wondering why the whole tree re-renders.
Common issues
Too many components re-render on one store updateConsumers subscribe to the whole store instead of a selector, or Context is used for frequent updates.
Subscribe with a narrow selector and split Context providers that change at different rates.
Stale data after a mutationThe query cache was never invalidated, so the UI shows the old snapshot.
Call queryClient.invalidateQueries after the mutation or update the cache with setQueryData.
State resets on navigationState lived in a component that unmounts, and nothing persisted it.
Lift the state to a store or persist it to localStorage/sessionStorage via a persist middleware.
Debugging tips
- Enable Redux or Zustand DevTools integration to time-travel through state updates.
- Install the React Query Devtools to watch cache entries, stale time and refetch activity.
- Profile re-renders in React DevTools to see which components a store update touches.
- Log reducer actions with a tiny middleware to replay the exact sequence that caused a bug.
Interview checkpoint
Hands-on lab
- Identify state duplicated across components
- Move it into a Zustand store
- Subscribe with narrow selectors
- Add persistence with the persist middleware
Refactor Shared State into a Store
35 min · advanced
Quick quiz
Which tool is designed to cache and synchronize server state?
All shared state should live in a global store.
Why do selector subscriptions in Zustand reduce re-renders?
Cheat sheet
useStateLocal, single componentLift to parentA few related siblingsuseReducerComplex update logicContextStatic shared valuesStore + selectorFrequent, many consumersQuery cacheData owned by an APIselectorsSubscribe to a slice of stateinvalidateQueriesRefresh server cache after mutationspersist middlewareWrite state to storagedevtools middlewareTime-travel debugging