Skip to main content

State management approaches in React

State management in React is the system for storing, updating, and sharing data across components, from a single useState call to a global store or a server cache.

Theory

TL;DR

  • useState is a personal shelf: fast, local, zero setup
  • Context is a shared fridge: app-wide, but every consumer re-renders on change
  • Zustand covers 90% of global state needs with almost no boilerplate (+3KB)
  • TanStack Query handles server state specifically: caching, background refetch, mutations
  • Decision rule: fewer than 3 components sharing data? useState. Complex async? TanStack Query. Global UI state? Zustand.

Quick example

tsx
// Local state: one component keeps its own counter function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(c => c + 1)}>{count}</button>; } // Shared state: theme available anywhere without prop drilling const ThemeContext = createContext<"light" | "dark">("light"); function App() { const [theme, setTheme] = useState<"light" | "dark">("light"); return ( <ThemeContext.Provider value={theme}> <Child /> {/* reads theme via useContext, no prop needed */} </ThemeContext.Provider> ); }

useState re-renders only its own component. Context re-renders every consumer. That gap matters at scale.

The core split

React state falls into three categories by scope. Local state (useState, useReducer) stays in one component and triggers re-renders only there. Shared state (Context, Zustand, Redux) broadcasts changes across the tree for data like user auth or a shopping cart. Server state (TanStack Query, SWR) is a separate category: it handles async fetches, caching, and background sync, things that have nothing to do with UI state.

Mixing these categories causes the most confusion I see in production codebases. A team puts API responses into Redux, then wonders why cache invalidation is so painful.

When to use each approach

  • Single component (toggle, input, modal open) → useState, no discussion needed
  • Complex local logic (multi-step form, undo history) → useReducer with a typed action union
  • 2-5 components sharing infrequently changing data (theme, locale, auth token) → Context API
  • App-wide state that changes often (cart, UI preferences, notifications) → Zustand for a minimal API or Redux Toolkit for larger teams with strict conventions
  • Data from an API (user list, search results, paginated posts) → TanStack Query or SWR
  • Complex forms with validation → React Hook Form, which is uncontrolled and avoids re-renders on every keystroke
  • Filters, pagination, sort order → URL params via URLSearchParams, which survive page refresh and are shareable

Comparison table

ApproachScopeBoilerplateAsync handlingBundle sizeBest for
useStateLocalNoneManual0UI toggles, inputs
useReducerLocal/sharedLowManual0Complex local logic
Context APIApp-wideLowManual0Theme, auth, locale
ZustandGlobalMinimalVia plugins+3KBMost apps
Redux ToolkitGlobalMediumRTK Query+12KBLarge teams, legacy
TanStack QueryServerLowBuilt-in cache+14KBAPI-heavy apps
React Hook FormFormsLowN/A+9KBValidation

How it works internally

React queues state updates in fiber nodes. Each useState call hooks into the dispatcher, attaching an update queue per hook index. In React 18, updates inside event handlers are automatically batched, so multiple setState calls produce one re-render instead of several.

Context providers create a tree walker. When the context value changes, React walks the subtree and marks consumer components for re-render. There is no built-in selector mechanism: if the value object changes reference, all consumers re-render. That is why placing a large object in Context without memoization is a performance problem.

TanStack Query uses a global Map keyed by query keys. Fetches go through AbortController, so navigating away from a page cancels in-flight requests. The Visibility API triggers background refetch when the user tabs back. All of this runs outside React's state model entirely.

Common mistakes

Lifting all state to the top and passing it down.

tsx
// Wrong: every child re-renders on any cart change function App() { const [cart, setCart] = useState([]); return <Header cart={cart} />; // Header re-renders even if it only shows item count } // Fix: subscribe to only what you need via a selector const cartCount = useCartStore(state => state.items.length); // Re-renders only when count changes, not on any cart mutation

Mutating state directly.

tsx
// Wrong: React won't detect this, no re-render happens const addItem = () => { cart.push(newItem); setCart(cart); // same reference, React bails out }; // Fix: create a new array reference setCart([...cart, newItem]);

Missing dependency in TanStack Query keys.

tsx
// Wrong: cache never updates when userId changes useQuery({ queryKey: ["posts"], queryFn: () => fetchPosts(userId) }); // Fix: userId belongs in the key useQuery({ queryKey: ["posts", userId], queryFn: () => fetchPosts(userId) });

Putting everything in one Context. When a single Context holds too much, any change re-renders the entire subtree. Split into smaller, focused Contexts or move to Zustand.

Real-world usage

  • Next.js apps at Vercel use TanStack Query for data fetching with stale-while-revalidate patterns
  • Shopify admin uses Redux Toolkit with structured slices for complex e-commerce state
  • Discord web client uses Zustand for lightweight global UI state
  • Stripe checkout uses React Hook Form for validation without per-keystroke re-renders
  • Most teams that reach for Redux first end up with Zustand after a refactor

Follow-up questions

Q: When does useState cause performance issues?
A: When the same state is shared across a deep tree via props. Every setState triggers re-renders in every consumer. The fix is moving to Context with memoization or to Zustand selectors.

Q: What is the difference between Zustand and Redux Toolkit?
A: Redux has more structure: actions, reducers, slices, and strict conventions that help large teams stay consistent. Zustand reaches the same result with a fraction of the code. For a team of 2-5 developers, Zustand is almost always the right pick.

Q: How does TanStack Query prevent request waterfalls?
A: Parallel queries via suspense boundaries and prefetchQuery at the router level, so data loads before components mount.

Q: What is wrong with useReducer plus Context for large apps?
A: Without selectors, every context value change re-renders the whole tree. There is no built-in way to subscribe to a slice of context. Zustand solves this with useStore(selector).

Q: In React 18, how do transitions affect state updates?
A: useTransition marks updates as low-priority. A heavy filter computation wrapped in a transition keeps the input responsive while results update in the background.

Q: Describe optimistic updates in TanStack Query.
A: In useMutation, set onMutate to update the cache immediately and save the previous value, then call onError to roll back if the server returns an error.

Examples

Local state: a controlled input

tsx
function SearchInput() { const [query, setQuery] = useState(""); return ( <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." /> ); } // Each keystroke updates query, only this component re-renders

Controlled inputs are the most common use of useState. The component owns the value, React owns the render cycle.

Global state: a Zustand cart store

tsx
import { create } from "zustand"; interface CartItem { id: number; qty: number; price: number; } interface CartStore { items: CartItem[]; addItem: (item: CartItem) => void; removeItem: (id: number) => void; total: () => number; } export const useCartStore = create<CartStore>((set, get) => ({ items: [], addItem: (item) => set(state => ({ items: [...state.items, item] })), removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id), })), total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0), })); // Only re-renders when item count changes, not on price updates function CartBadge() { const count = useCartStore(state => state.items.length); return <span className="badge">{count}</span>; }

The selector state => state.items.length is what makes Zustand performant here. CartBadge re-renders only when the count changes, not when individual prices or quantities update.

Server state: TanStack Query with a mutation

tsx
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; function UserList() { const queryClient = useQueryClient(); const { data: users, isLoading } = useQuery({ queryKey: ["users"], queryFn: () => fetch("/api/users").then(r => r.json()), }); const deleteUser = useMutation({ mutationFn: (id: number) => fetch(`/api/users/${id}`, { method: "DELETE" }), onSuccess: () => { // Marks cache as stale, triggers automatic background refetch queryClient.invalidateQueries({ queryKey: ["users"] }); }, }); if (isLoading) return <p>Loading...</p>; return ( <ul> {users.map(user => ( <li key={user.id}> {user.name} <button onClick={() => deleteUser.mutate(user.id)}>Delete</button> </li> ))} </ul> ); }

invalidateQueries after a mutation marks the cache as stale and triggers a background refetch. No manual useEffect, no state juggling.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?