State Management

State in React is just data that changes over time and triggers re-renders. The hard part is deciding where it lives and how it flows. Start simple, add complexity only when you feel the pain.

Local State: useState / useReducer

Most state should be local. If only one component (and its children) need it, keep it there.

// Simple values - useState
function SearchBar() {
  const [query, setQuery] = useState("");
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {isOpen && <SearchResults query={query} />}
    </div>
  );
}
// Complex state transitions - useReducer
interface CartState {
  items: CartItem[];
  discount: number;
  shipping: "standard" | "express";
}

type CartAction =
  | { type: "ADD_ITEM"; item: CartItem }
  | { type: "REMOVE_ITEM"; id: string }
  | { type: "UPDATE_QTY"; id: string; qty: number }
  | { type: "APPLY_DISCOUNT"; code: string }
  | { type: "SET_SHIPPING"; method: "standard" | "express" };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "ADD_ITEM":
      return { ...state, items: [...state.items, action.item] };
    case "REMOVE_ITEM":
      return { ...state, items: state.items.filter((i) => i.id !== action.id) };
    case "UPDATE_QTY":
      return {
        ...state,
        items: state.items.map((i) =>
          i.id === action.id ? { ...i, qty: action.qty } : i
        ),
      };
    case "APPLY_DISCOUNT":
      return { ...state, discount: lookupDiscount(action.code) };
    case "SET_SHIPPING":
      return { ...state, shipping: action.method };
  }
}

function Cart() {
  const [cart, dispatch] = useReducer(cartReducer, {
    items: [],
    discount: 0,
    shipping: "standard",
  });

  // dispatch({ type: "ADD_ITEM", item: { id: "1", name: "Widget", qty: 1, price: 9.99 } })
}

Tip: Use useState for independent values (a toggle, a text input). Use useReducer when state transitions depend on previous state or multiple values change together (forms, wizards, shopping carts).

Shared State: Context + Custom Hooks

When multiple components at different levels of the tree need the same state, use React Context. Wrap it in a custom hook for a clean API.

// src/hooks/useAuth.tsx
import { createContext, useContext, useState, type ReactNode } from "react";

interface AuthState {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthState | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  async function login(email: string, password: string) {
    const res = await fetch("/api/login", {
      method: "POST",
      body: JSON.stringify({ email, password }),
    });
    const data = await res.json();
    setUser(data.user);
  }

  function logout() {
    setUser(null);
    fetch("/api/logout", { method: "POST" });
  }

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be inside AuthProvider");
  return ctx;
}
// Usage anywhere in the tree
function Header() {
  const { user, logout } = useAuth();
  return user ? (
    <div>
      {user.name} <button onClick={logout}>Log out</button>
    </div>
  ) : (
    <LoginButton />
  );
}

When Context Becomes a Problem

Context re-renders every consumer when the value changes, even if they only use part of it.

// Bad: one giant context that changes frequently
const AppContext = createContext({
  user: null,
  theme: "dark",
  notifications: [],
  cart: { items: [] },
  // changing ANY of these re-renders ALL consumers
});

// Good: split into focused contexts
<AuthProvider>
  <ThemeProvider>
    <NotificationProvider>
      <CartProvider>
        <App />
      </CartProvider>
    </NotificationProvider>
  </ThemeProvider>
</AuthProvider>

Gotcha: Context is not a global state manager. It’s a dependency injection mechanism. If you’re updating context values 60 times per second (mouse position, animations), consumers will re-render 60 times per second. Use refs or external stores for high-frequency updates.

Server State: TanStack Query

Data from your API is not the same as UI state. It has different concerns: caching, staleness, refetching, deduplication. TanStack Query handles all of this.

// This is not "state management" - it's cache management
function useUser(id: string) {
  return useQuery({
    queryKey: ["user", id],
    queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
    staleTime: 5 * 60_000, // cached for 5 minutes
  });
}

// Multiple components can call useUser("123") -
// only one fetch happens. Cache is shared.
function UserProfile({ id }: { id: string }) {
  const { data: user } = useUser(id);
  return <h1>{user?.name}</h1>;
}

function UserAvatar({ id }: { id: string }) {
  const { data: user } = useUser(id);
  return <img src={user?.avatar} />;
}

See Data Fetching for the full TanStack Query setup.

Complex Client State: Zustand vs Redux

When you need client state that’s too complex for Context (frequent updates, computed values, middleware), reach for an external store.

Minimal API, no boilerplate, works outside React.

npm install zustand
import { create } from "zustand";

interface Store {
  items: Item[];
  filter: "all" | "active" | "completed";
  addItem: (title: string) => void;
  toggleItem: (id: string) => void;
  setFilter: (filter: Store["filter"]) => void;
}

const useStore = create<Store>((set) => ({
  items: [],
  filter: "all",

  addItem: (title) =>
    set((state) => ({
      items: [
        ...state.items,
        { id: crypto.randomUUID(), title, completed: false },
      ],
    })),

  toggleItem: (id) =>
    set((state) => ({
      items: state.items.map((i) =>
        i.id === id ? { ...i, completed: !i.completed } : i
      ),
    })),

  setFilter: (filter) => set({ filter }),
}));

// Usage - components only re-render when their selected slice changes
function FilterBar() {
  const filter = useStore((s) => s.filter);       // only re-renders when filter changes
  const setFilter = useStore((s) => s.setFilter);
  return <select value={filter} onChange={(e) => setFilter(e.target.value as any)} />;
}

function ItemCount() {
  const count = useStore((s) => s.items.filter((i) => !i.completed).length);
  return <span>{count} remaining</span>; // only re-renders when count changes
}

Redux Toolkit (When You Need It)

More structure, middleware, devtools. Best for large apps with many developers.

npm install @reduxjs/toolkit react-redux
import { createSlice, configureStore, type PayloadAction } from "@reduxjs/toolkit";
import { Provider, useSelector, useDispatch } from "react-redux";

const todosSlice = createSlice({
  name: "todos",
  initialState: [] as Item[],
  reducers: {
    add(state, action: PayloadAction<string>) {
      state.push({ id: crypto.randomUUID(), title: action.payload, completed: false });
    },
    toggle(state, action: PayloadAction<string>) {
      const todo = state.find((t) => t.id === action.payload);
      if (todo) todo.completed = !todo.completed;
    },
  },
});

const store = configureStore({ reducer: { todos: todosSlice.reducer } });
type RootState = ReturnType<typeof store.getState>;

// Wrap app in Provider
<Provider store={store}><App /></Provider>

// Use in components
function TodoList() {
  const todos = useSelector((s: RootState) => s.todos);
  const dispatch = useDispatch();
  return (
    <ul>
      {todos.map((t) => (
        <li key={t.id} onClick={() => dispatch(todosSlice.actions.toggle(t.id))}>
          {t.title}
        </li>
      ))}
    </ul>
  );
}

Decision Tree

Is it server data (from an API)?
├── Yes → TanStack Query (or server components)
└── No, it's UI state
    ├── Used by one component? → useState / useReducer
    ├── Shared by a few related components? → Lift state up (pass via props)
    ├── Shared across distant components?
    │   ├── Changes infrequently? → Context + custom hook
    │   └── Changes frequently? → Zustand
    └── Large app, many developers, need middleware? → Redux Toolkit
LibraryBundle sizeBoilerplateBest for
Context0 KBLowTheme, auth, locale
Zustand~1 KBMinimalMost client state needs
Redux Toolkit~11 KBModerateLarge teams, complex middleware
Jotai~3 KBMinimalAtomic state (many independent pieces)
Valtio~3 KBMinimalMutable-style API (proxy-based)

Exercises

  1. Build a theme switcher using Context. Support light/dark/system modes. Persist to localStorage. Use a custom useTheme hook.

  2. Build a shopping cart with Zustand. Support add/remove/update quantity. Compute total price as a derived value. Persist to localStorage using Zustand’s persist middleware:

import { persist } from "zustand/middleware";

const useCart = create(
  persist<CartStore>(
    (set) => ({
      items: [],
      addItem: (item) => set((s) => ({ items: [...s.items, item] })),
    }),
    { name: "cart-storage" }
  )
);
  1. Refactor a component that uses useEffect to fetch data into TanStack Query. Compare the before/after in terms of loading states, error handling, and caching.

Next: React Compiler to eliminate manual memoization