Notifications

No notifications

/Phase 4

State Management

State Management — Scaling React Data Flow

As applications grow, managing state across many components becomes complex. React provides Context API built-in, while libraries like Zustand and Redux Toolkit offer more powerful solutions.

When to Use What

SolutionBest ForComplexity
useStateLocal component stateLow
Lifting state2-3 sibling componentsLow
Context APITheme, auth, locale (low-frequency updates)Medium
ZustandMedium apps, simple global storeLow-Medium
Redux ToolkitLarge apps, complex logic, middlewareHigh

Context API

const ThemeContext = createContext("light");

// Provider wraps the tree <ThemeContext.Provider value="dark"> <App /> </ThemeContext.Provider>

// Consumer reads the value anywhere in the tree const theme = useContext(ThemeContext);

Zustand — Minimal Global State

import { create } from "zustand";

const useStore = create((set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })), }));

// In any component: const count = useStore((s) => s.count);

Redux Toolkit — Structured State

Uses slices (reducers + actions in one file), configureStore, and useSelector/useDispatch hooks.

Key Principles

  • Keep state as local as possible
  • Derive computed values instead of storing them
  • Use the simplest tool that solves the problem
  • Avoid premature global state — lift first, then context, then external library

On this page

Detailed Theory

Why a Whole Topic for "State"?

You already know useState. Why is there a whole library industry around state? Because as an app grows you start running into:

  • Prop drilling — passing the same data through 5 components that don't need it.
  • Shared state — the navbar shows a cart count that the product page updates.
  • Server data — half your "state" is really cached responses from an API.
  • Cross-page persistence — login info, theme, language must survive route changes.
Different problems → different tools. Picking the right one is half the skill.

The Three Kinds of State

Before choosing a library, label what you're storing:

KindExampleWhere it belongs
Local UI stateinput value, modal openuseState in the component
Shared client statetheme, current user, cartContext / Zustand / Redux
Server cache statelist of users from APITanStack Query / SWR
URL statefilters, page number, tabURL search params

A common mistake is shoving server data into Redux. Server data already lives on the server — what you really want is a *cache* with refetch logic. That's what TanStack Query does.

Tool 1: useState — Always Start Here

If only one component (or a couple of close children) need it, plain useState is the answer. Don't reach for a library yet.

Tool 2: Lifting State Up

If two siblings need the same value, lift it to their nearest common parent (covered in State & Props). Free, zero dependencies.

function Page() {
  const [filter, setFilter] = useState("");
  return (
    <>
      <SearchBox filter={filter} onChange={setFilter} />
      <Results filter={filter} />
    </>
  );
}

Tool 3: Context API — Skip the Drilling

When data must reach deeply nested components (theme, current user, locale), use Context.

import { createContext, useContext, useState } from "react";

const AuthContext = createContext(null);

export function AuthProvider({ children }) { const [user, setUser] = useState(null); return ( <AuthContext.Provider value={{ user, setUser }}> {children} </AuthContext.Provider> ); }

export const useAuth = () => useContext(AuthContext);

Wrap your app once with ; any descendant calls useAuth().

Tool 4: A Real State Library

When state is updated *frequently* and read in *many* places, Context starts re-rendering too aggressively. Pick one:

  • Zustand — tiny, hook-based, no boilerplate. Sweet default for most apps.
  • Redux Toolkit — bigger ecosystem, devtools, time-travel debugging, predictable patterns. Common at large companies.
  • Jotai / Recoil — atom-based, very granular subscriptions.
  • TanStack Query / SWR — for server cache only.

Zustand in 30 Seconds

import { create } from "zustand";

const useCart = create((set) => ({ items: [], addItem: (item) => set((s) => ({ items: [...s.items, item] })), remove: (id) => set((s) => ({ items: s.items.filter(x => x.id !== id) })), total: () => 0, // see selectors below }));

function CartCount() { const count = useCart((s) => s.items.length); // selector return <span>{count}</span>; }

The selector matters: this component only re-renders when items.length changes — not when *any* part of the cart changes.

Redux Toolkit in 30 Seconds

import { configureStore, createSlice } from "@reduxjs/toolkit";

const counter = createSlice({ name: "counter", initialState: { value: 0 }, reducers: { increment: (s) => { s.value += 1; }, // looks like mutation, safe via Immer addBy: (s, action) => { s.value += action.payload; }, }, });

export const { increment, addBy } = counter.actions; export const store = configureStore({ reducer: { counter: counter.reducer } });

// In a component: const value = useSelector((s) => s.counter.value); const dispatch = useDispatch(); dispatch(increment());

Beginner Mistakes to Skip

1. Reaching for Redux on day one — try useState → lift → Context → Zustand → Redux, in that order. 2. Putting server data in client state — use TanStack Query / SWR; they handle caching, refetching, retries. 3. One giant Context holding theme + user + cart — splitting into focused contexts avoids needless re-renders. 4. Mutating Redux state without Immer's slice helpers — outside Redux Toolkit it'll silently break. 5. Not memoising the Context value creates a new object every render, re-rendering all consumers.

Intermediate: Context API Pitfalls

Whenever a Context's value changes, every consuming component re-renders — even if it only reads one field. Workarounds:

  • Split contexts: AuthContext, ThemeContext, CartContext — components subscribe only to what they need.
  • Memoise the value:
const value = useMemo(() => ({ user, setUser }), [user]);
  return <AuthContext.Provider value={value}>…</AuthContext.Provider>;
  • React.memo consumers that don't need to re-render on every change.
If updates happen many times per second, switch to a real store (Zustand etc.).

Intermediate: Zustand Selectors & Shallow Comparison

Selecting an object re-renders on every store change because {a:1} !== {a:1}. Use a shallow comparator:

import { shallow } from "zustand/shallow";

const { addItem, remove } = useCart( (s) => ({ addItem: s.addItem, remove: s.remove }), shallow, );

Or pull each value with its own one-liner selector — keeps re-renders minimal.

Intermediate: Redux Toolkit Architecture

ConceptDescription
StoreSingle source of truth
SliceReducer + actions for one feature
DispatchSends actions to the store
SelectorReads specific state from the store
ThunkAsync middleware for side effects (createAsyncThunk)

Modern Redux Toolkit also ships RTK Query, which competes head-to-head with TanStack Query for server cache.

Intermediate: Server State with TanStack Query

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

function Users() { const { data, isLoading, error } = useQuery({ queryKey: ["users"], queryFn: () => fetch("/api/users").then(r => r.json()), });

if (isLoading) return <Spinner />; if (error) return <Error err={error} />; return <List items={data} />; }

You get for free:

  • Caching (multiple components sharing the same key share the cache)
  • Background refetch on window focus
  • Retries on failure
  • Loading + error states
  • Optimistic updates
If your "state" came from a server, this is almost certainly the right tool.

Intermediate: URL as State

Filters, sort order, pagination, search — all great in the URL. Use useSearchParams (covered in Router) so refresh, share, and back-button work for free. Don't duplicate them in client state.

Advanced: Selector Patterns

A selector is just a function state => value. Two reasons to extract them:

1. Reuse — every component reading isLoggedIn uses the same logic. 2. Memoisation — for derived values, use createSelector (Reselect / RTK) so the same inputs return the same reference, preventing re-renders.

import { createSelector } from "reselect";

const selectItems = (s) => s.cart.items; const selectTotal = createSelector([selectItems], (items) => items.reduce((sum, it) => sum + it.price, 0) );

Advanced: Persisting State

Pretty much every library has a "persist" plugin that mirrors state to localStorage:

  • Zustand: persist middleware
  • Redux: redux-persist
  • TanStack Query: persistQueryClient
Useful for theme, drafts, and offline support — but think twice before persisting auth tokens (XSS risk).

Advanced: Concurrency-Safe Stores

React 18's concurrent rendering can render a component twice in the background. External stores must use useSyncExternalStore to stay consistent. Major libraries already do this internally — but if you build a custom store, you must too.

Advanced: When to Pick Which Tool

Just one component?              → useState
A few siblings?                  → Lift state
Cross-tree, low-frequency?       → Context
Cross-tree, high-frequency?      → Zustand
Big team, devtools, conventions? → Redux Toolkit
Server data?                     → TanStack Query / SWR / RTK Query
Filters / pagination?            → URL search params

You'll often combine several: TanStack Query for server data + Zustand (or Context) for the rest.

Practice Path

1. Build a theme toggle with Context, then split it into and a useTheme() hook. 2. Build a cart with Zustand. Make re-render *only* when the count changes (verify with React DevTools profiler). 3. Replace a manual useEffect + fetch flow with TanStack Query. Remove all the loading/error boolean state. 4. Move pagination + search filter from useState into URL search params; refresh and confirm the page restores.