State ManagementReduxZustandContext APIReact QueryArchitecture

State Management Patterns

State is the data that drives your UI. Where it lives, how it flows, and how it's updated determines whether your app is predictable or chaotic. Understanding the spectrum — from useState to Redux to server state — is essential for frontend architecture decisions.

24 min read15 sections
01

Overview

State management is how you store, update, and share data across your application. In React, state drives the UI — when state changes, components re-render. The challenge is deciding where state lives and how it flows between components.

For a small app, useState is enough. As the app grows, you need patterns for sharing state across components (lifting state, Context), managing complex state transitions (useReducer, Redux), and handling server data (React Query, SWR). Choosing the wrong pattern leads to prop drilling, unnecessary re-renders, and unmaintainable code.

In interviews, "How would you manage state in this application?" tests whether you can match the right tool to the right problem — not whether you know Redux syntax. The best answer explains the spectrum of options and justifies the choice.

Why this matters

State management is the single biggest architectural decision in a frontend app. It affects performance (re-renders), developer experience (debugging), and scalability (adding features). Get it right and the app is predictable. Get it wrong and every feature becomes a fight against the framework.

02

The Problem: Managing State at Scale

Simple state management works until it doesn't. As applications grow, three problems emerge that basic patterns can't solve cleanly.

Problem 1: Prop Drillingjsx
// User data needed 4 levels deep
function App() {
  const [user, setUser] = useState({ name: "Alice", role: "admin" });

  return <Layout user={user} />;           // pass down
}

function Layout({ user }) {
  return <Sidebar user={user} />;           // pass down again
}

function Sidebar({ user }) {
  return <UserMenu user={user} />;          // pass down AGAIN
}

function UserMenu({ user }) {
  return <span>{user.name}</span>;          // finally used here
}

// Layout and Sidebar don't USE user — they just pass it through.
// This is "prop drilling": threading props through components
// that don't need them, just to reach a deeply nested child.
//
// Problems:
// ❌ Every intermediate component must know about "user" prop
// ❌ Adding a new field to user means updating 4 components
// ❌ Renaming the prop means editing every level
// ❌ Components become coupled to data they don't use
Problem 2: Shared State Across Siblingstext
Component Tree:
                    App
                   /   \
              Header    Content
              /            \
         CartIcon       ProductList
            │                │
     "Show cart count"  "Add to cart"

  CartIcon needs to display the cart count.
  ProductList needs to add items to the cart.
  They're siblings — neither is a parent of the other.

  Where does cart state live?
In CartIcon? → ProductList can't update it
In ProductList? → CartIcon can't read it
In App? → Prop drilling through Header and Content
In Context? → Re-renders every consumer on any change
In Zustand/Redux? → External store, both read/write directly
🔽

Prop Drilling

Passing data through 3-5 levels of components that don't use it. Makes refactoring painful and couples components to data they don't care about.

🔄

State Synchronization

Two components showing the same data (cart count in header + cart page). If they have separate state, they can get out of sync. Single source of truth is essential.

Re-render Cascades

Updating state high in the tree re-renders everything below it. A theme toggle in App re-renders the entire application — even components that don't use the theme.

The core question

Every state management decision answers: "Where does this state live, and how do the components that need it access it?" The answer depends on how many components need the state, how often it changes, and whether it comes from the server or the client.

03

Types of State

Not all state is the same. Categorizing state by scope and origin is the first step to choosing the right management pattern.

State Categoriestext

By Scope:
┌──────────────────────────────────────────────────┐
LOCAL STATE
│  • Belongs to one component
│  • Examples: form input, toggle, dropdown open
│  • Tool: useState
├──────────────────────────────────────────────────┤
SHARED STATE
│  • Needed by 2-5 nearby components
│  • Examples: selected tab, filter criteria
│  • Tool: lift state up, or Context
├──────────────────────────────────────────────────┤
GLOBAL STATE
│  • Needed across the entire app
│  • Examples: auth user, theme, locale
│  • Tool: Context, Zustand, Redux
└──────────────────────────────────────────────────┘

By Origin:
┌──────────────────────────────────────────────────┐
CLIENT STATE
│  • Created and owned by the frontend
│  • Examples: UI state, form data, selections
│  • Tool: useState, useReducer, Zustand, Redux
├──────────────────────────────────────────────────┤
SERVER STATE
│  • Fetched from an API, cached on the client
│  • Examples: user profile, product list, orders
│  • Tool: React Query, SWR, RTK Query
│  • Has unique concerns: caching, revalidation,   │
loading states, optimistic updates
└──────────────────────────────────────────────────┘
State TypeExampleScopeBest Tool
Form input valueSearch query textLocaluseState
Dropdown open/closedisMenuOpenLocaluseState
Selected filterActive category tabShared (2-3 components)Lift state up
Shopping cartCart items arrayGlobal (header + cart page)Zustand / Redux
Auth userCurrent logged-in userGlobal (entire app)Context or Zustand
Theme / localeDark mode, languageGlobal (rarely changes)Context
Product listFetched from /api/productsServer stateReact Query / SWR
User profileFetched from /api/meServer stateReact Query / SWR

The most important distinction

Server state and client state are fundamentally different. Server state is a cache of remote data — it has loading/error states, can become stale, and needs revalidation. Client state is owned by the frontend — it's synchronous and always up-to-date. Mixing them in the same store (putting API data in Redux) is the #1 state management mistake.

04

Local State (useState)

The simplest and most common pattern. State lives inside a single component and is managed with useState or useReducer. No other component can access it directly.

Local State Examplesjsx
// Simple: boolean toggle
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Menu</button>
      {isOpen && <ul className="dropdown-menu">...</ul>}
    </div>
  );
}

// Medium: form with multiple fields
function ContactForm() {
  const [form, setForm] = useState({ name: "", email: "", message: "" });
  const [errors, setErrors] = useState({});

  const handleChange = (field, value) => {
    setForm(prev => ({ ...prev, [field]: value }));
  };

  const handleSubmit = () => {
    const newErrors = validate(form);
    if (Object.keys(newErrors).length === 0) {
      submitForm(form);
    } else {
      setErrors(newErrors);
    }
  };

  return (/* form JSX */);
}

// Complex: useReducer for state machines
function TodoList() {
  const [state, dispatch] = useReducer(todoReducer, { todos: [], filter: "all" });

  return (
    <div>
      <button onClick={() => dispatch({ type: "ADD", text: "New todo" })}>Add</button>
      <button onClick={() => dispatch({ type: "SET_FILTER", filter: "done" })}>Done</button>
      {/* render filtered todos */}
    </div>
  );
}

// When to use local state:
// ✅ Only one component needs this data
// ✅ UI state: open/closed, active tab, hover, form inputs
// ✅ State doesn't need to survive component unmount
// ✅ No other component needs to read or write this state

Default to local state

Start with useState for everything. Only move state "up" or "out" when another component actually needs it. This is called state colocation — keeping state as close to where it's used as possible. It's the simplest pattern and causes the fewest re-renders.

05

Lifting State Up

When two sibling components need the same state, move it to their nearest common parent. The parent owns the state and passes it down as props.

Lifting State Upjsx
// ❌ BEFORE: Duplicated state — siblings can't share
function SearchBar() {
  const [query, setQuery] = useState("");  // SearchBar owns query
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

function ResultsList() {
  const [query, setQuery] = useState("");  // ResultsList has its OWN query
  // These two "query" states are independent — they don't sync!
  return <ul>{/* filter by query */}</ul>;
}

// ✅ AFTER: Lifted state — parent owns, children share
function SearchPage() {
  const [query, setQuery] = useState("");  // Parent owns the state

  return (
    <div>
      <SearchBar query={query} onQueryChange={setQuery} />
      <ResultsList query={query} />
    </div>
  );
}

function SearchBar({ query, onQueryChange }) {
  return (
    <input
      value={query}
      onChange={e => onQueryChange(e.target.value)}
    />
  );
}

function ResultsList({ query }) {
  const filtered = allResults.filter(r =>
    r.title.toLowerCase().includes(query.toLowerCase())
  );
  return <ul>{filtered.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}

// Now SearchBar and ResultsList share the same query.
// SearchBar writes it (via callback), ResultsList reads it (via prop).
// Single source of truth — always in sync.
ProsCons
Simple — no extra libraries or patternsCan lead to prop drilling if state is needed deep in the tree
Single source of truth — siblings always in syncParent re-renders on every state change (and all children)
Easy to understand and debugDoesn't scale beyond 2-3 levels of prop passing
Works for 2-5 components sharing stateNot suitable for app-wide state (auth, theme)

When lifting state breaks down

Lifting state works for nearby components (parent + 2-3 children). When state needs to be shared across distant parts of the tree (header and footer, sidebar and main content), lifting it to the root causes prop drilling and unnecessary re-renders. That's when you need Context or an external store.

06

Context API

React Context lets you pass data through the component tree without prop drilling. Any component inside the Provider can access the value directly, regardless of depth.

Context API Patternjsx
// 1. Create the context
const AuthContext = createContext(null);

// 2. Create a provider component
function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

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

  const logout = () => setUser(null);

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

// 3. Create a custom hook for clean access
function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within AuthProvider");
  return context;
}

// 4. Wrap the app
function App() {
  return (
    <AuthProvider>
      <Header />    {/* can access user */}
      <MainContent /> {/* can access user */}
      <Footer />    {/* can access user */}
    </AuthProvider>
  );
}

// 5. Use anywhere — no prop drilling
function UserMenu() {
  const { user, logout } = useAuth();

  if (!user) return <LoginButton />;
  return (
    <div>
      <span>{user.name}</span>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

// UserMenu accesses auth directly — no props passed through
// Header, Layout, Sidebar, etc.
Context is Good ForContext is Bad For
Theme (dark/light) — changes rarelyShopping cart — changes frequently, many consumers
Auth user — read by many, updated rarelyForm state — local to one component
Locale/language — app-wide, stableReal-time data — high update frequency
Feature flags — set once, read everywhereComplex state with many actions (use Zustand/Redux)

The re-render problem

When a Context value changes, every component that calls useContext re-renders — even if it only uses one field from the value. For frequently changing state with many consumers, this causes performance issues. That's why Context is best for low-frequency, app-wide state (theme, auth, locale).

07

External State Libraries

When Context isn't enough — frequent updates, complex state logic, or fine-grained subscriptions — external libraries provide more powerful patterns.

Zustand — Minimal Global Storejsx
import { create } from "zustand";

// Create a store — no Provider needed
const useCartStore = create((set) => ({
  items: [],

  addItem: (product) =>
    set((state) => ({
      items: [...state.items, { ...product, quantity: 1 }],
    })),

  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),

  clearCart: () => set({ items: [] }),

  // Derived state
  get totalItems() {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  },
}));

// Use in any component — no Provider wrapper
function CartIcon() {
  const totalItems = useCartStore((state) => state.items.length);
  return <span className="badge">{totalItems}</span>;
}

function ProductCard({ product }) {
  const addItem = useCartStore((state) => state.addItem);
  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={() => addItem(product)}>Add to Cart</button>
    </div>
  );
}

// Key advantages over Context:
// ✅ No Provider wrapper needed
// ✅ Selector-based subscriptions — CartIcon only re-renders
//    when items.length changes, not on every state update
// ✅ Simple API — create() + use the hook
// ✅ Works outside React (in utility functions, middleware)
Redux Toolkit — Structured Global Storejsx
import { createSlice, configureStore } from "@reduxjs/toolkit";

// Define a slice (feature-scoped state + reducers)
const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] },
  reducers: {
    addItem: (state, action) => {
      state.items.push({ ...action.payload, quantity: 1 });
    },
    removeItem: (state, action) => {
      state.items = state.items.filter(item => item.id !== action.payload);
    },
    clearCart: (state) => {
      state.items = [];
    },
  },
});

// Create the store
const store = configureStore({
  reducer: { cart: cartSlice.reducer },
});

// Use in components with useSelector + useDispatch
function CartIcon() {
  const itemCount = useSelector(state => state.cart.items.length);
  return <span>{itemCount}</span>;
}

function ProductCard({ product }) {
  const dispatch = useDispatch();
  return (
    <button onClick={() => dispatch(cartSlice.actions.addItem(product))}>
      Add to Cart
    </button>
  );
}

// Redux is best for:
// ✅ Large apps with complex state logic
// ✅ Teams that need strict patterns and predictability
// ✅ DevTools with time-travel debugging
// ✅ Middleware for side effects (thunks, sagas)
LibraryBoilerplateLearning CurveBest For
ZustandMinimalLowSmall-medium apps, simple global state
Redux ToolkitMediumMediumLarge apps, complex state, team conventions
JotaiMinimalLowAtomic state, fine-grained reactivity
RecoilMediumMediumDerived state, async selectors (Meta projects)

Zustand is the modern default

For most new React projects, Zustand has become the go-to choice. It has minimal boilerplate, no Provider wrapper, built-in selector subscriptions (no unnecessary re-renders), and works outside React. Redux Toolkit is still excellent for large teams that need strict conventions and powerful DevTools.

08

Server State vs Client State

This is the most important distinction in modern state management. Server state and client state have fundamentally different characteristics and need different tools.

The Differencetext
CLIENT STATE                        SERVER STATE
─────────────                       ────────────
Created by the frontendFetched from an API
Synchronousalways availableAsynchronousloading/error states
Always up-to-date (you own it)    • Can become stale (server may have changed)
No caching neededCaching is critical (avoid refetching)
Examples:                         • Examples:
  - isMenuOpen                        - User profile from /api/me
  - selectedTab                       - Product list from /api/products
  - formInputValue                    - Order history from /api/orders
  - theme (dark/light)                - Notifications from /api/notifications

Tools:                              Tools:
  useState, useReducer,               React Query (TanStack Query),
  Zustand, Redux                      SWR, RTK Query

The mistake: putting server data in Redux/Zustand
You must manually handle loading, error, caching,
     revalidation, pagination, optimistic updates...
React Query handles ALL of this automatically.
Server State with React Queryjsx
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

// Fetching server state — React Query handles everything
function UserProfile() {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ["user", "me"],
    queryFn: () => fetch("/api/me").then(res => res.json()),
    staleTime: 5 * 60 * 1000,  // cache for 5 minutes
  });

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return <ProfileCard user={user} />;
}

// Mutating server state — with optimistic update
function TodoItem({ todo }) {
  const queryClient = useQueryClient();

  const toggleMutation = useMutation({
    mutationFn: (id) =>
      fetch(`/api/todos/${id}/toggle`, { method: "PATCH" }),

    // Optimistic update: update UI immediately
    onMutate: async (id) => {
      await queryClient.cancelQueries({ queryKey: ["todos"] });
      const previous = queryClient.getQueryData(["todos"]);
      queryClient.setQueryData(["todos"], (old) =>
        old.map(t => t.id === id ? { ...t, done: !t.done } : t)
      );
      return { previous };
    },

    // Rollback on error
    onError: (err, id, context) => {
      queryClient.setQueryData(["todos"], context.previous);
    },
  });

  return (
    <li onClick={() => toggleMutation.mutate(todo.id)}>
      {todo.text}
    </li>
  );
}

// What React Query gives you for free:
// ✅ Loading & error states
// ✅ Automatic caching & deduplication
// ✅ Background refetching (stale-while-revalidate)
// ✅ Optimistic updates with rollback
// ✅ Pagination & infinite scroll support
// ✅ Automatic garbage collection of unused data
Client State (Zustand/Redux)Server State (React Query)
Data sourceCreated in the browserFetched from API
Loading stateNot needed (synchronous)Built-in (isLoading, isFetching)
CachingNot neededAutomatic with staleTime/cacheTime
StalenessAlways fresh (you own it)Can become stale (server may change)
RevalidationNot applicableAutomatic (on focus, on reconnect, on interval)
DeduplicationManualAutomatic (same queryKey = one request)

The modern stack

The recommended approach for most apps: React Query for server state + Zustand (or useState) for client state. This eliminates 80% of what Redux was traditionally used for. Redux was often used to cache API data — React Query does that better with less code.

09

Pattern Comparison

Here's the full landscape. Each pattern has a sweet spot — the key is matching the pattern to the problem.

PatternComplexityScopeRe-render ControlBest For
useStateTrivialSingle componentPerfect (local only)UI state, form inputs, toggles
Lift state upLowParent + 2-3 childrenGood (parent re-renders children)Sibling communication, shared filters
useReducerLowSingle componentPerfect (local only)Complex state transitions, state machines
Context APIMediumSubtree or app-widePoor (all consumers re-render)Theme, auth, locale (low-frequency)
ZustandMediumApp-wideGreat (selector subscriptions)Global client state, cart, UI preferences
Redux ToolkitMedium-HighApp-wideGreat (selector subscriptions)Large apps, complex logic, team conventions
React QueryMediumServer data cacheGreat (query-level subscriptions)API data, caching, revalidation

They're not mutually exclusive

A real app uses multiple patterns simultaneously. useState for form inputs, Context for theme/auth, Zustand for cart state, React Query for API data. The skill is knowing which pattern to use for which type of state.

10

Decision Framework

Use this decision tree when choosing a state management approach. Start from the top and follow the path that matches your situation.

State Management Decision Treetext
Is this data from an API (server state)?

├─ YESUse React Query / SWR / RTK Query
Handles caching, loading, revalidation automatically.
Don't put API data in Redux or Zustand.

└─ NOIt's client state. How many components need it?

        ├─ ONE componentuseState (or useReducer if complex)
Keep it local. Don't over-engineer.

        ├─ 2-3 NEARBY componentsLift state up
Move state to nearest common parent.
Pass via props. Simple and effective.

        ├─ MANY components, CHANGES RARELYContext API
Theme, auth user, locale, feature flags.
Wrap in Provider, access with useContext.

        └─ MANY components, CHANGES OFTENZustand / Redux
            Shopping cart, notifications, complex UI state.
            Selector-based subscriptions prevent re-render storms.

Quick rules:
Default to useStatemove up only when needed
Server dataReact Query (never Redux for API caching)
Global + low frequencyContext
Global + high frequencyZustand or Redux
Complex transitionsuseReducer (local) or Redux (global)
1

Ask: Is it server state or client state?

If the data comes from an API, use React Query. Full stop. It handles caching, loading states, revalidation, and deduplication. Don't reinvent this with Redux or Zustand.

2

Ask: How many components need this state?

One component → useState. Two nearby siblings → lift state up. Many distant components → Context or external store. The answer determines the pattern.

3

Ask: How often does this state change?

Rarely (theme, auth) → Context is fine. Frequently (cart, form, real-time) → Zustand or Redux with selectors. Context re-renders all consumers on every change.

4

Ask: How complex are the state transitions?

Simple (toggle, set value) → useState. Complex (multi-step, conditional) → useReducer locally or Redux for global. Reducers make complex transitions predictable.

The interview answer

"I start with useState for everything. When two components need the same state, I lift it up. For app-wide state that changes rarely (theme, auth), I use Context. For app-wide state that changes often (cart, notifications), I use Zustand. For server data, I use React Query. I never put API data in a client state store."

11

Real-World Example

Let's architect the state management for a real e-commerce dashboard, showing how different patterns coexist in one application.

E-commerce Dashboard — State Architecturetext

┌─────────────────────────────────────────────────┐
App
│                                                 │
Context: AuthProvider (user, login, logout)    │
Context: ThemeProvider (theme, toggleTheme)    │
│                                                 │
│  ┌──────────────────────────────────────────┐   │
│  │  Header                                  │   │
│  │  ├── UserMenu (useAuthContext)        │   │
│  │  ├── CartIcon (useCartStoreZustand)   │   │
│  │  └── ThemeToggle (useThemeContext)    │   │
│  └──────────────────────────────────────────┘   │
│                                                 │
│  ┌───────────────────────────────────────────┐  │
│  │  Dashboard Page                           │  │
│  │  ├── StatsCards (useQueryReact Query)  │  │
│  │  ├── RevenueChart (useQueryReact Query)│  │
│  │  ├── RecentOrders (useQueryReact Query)│  │
│  │  └── FilterBar (useStatelocal)         │  │
│  └───────────────────────────────────────────┘  │
│                                                 │
│  ┌──────────────────────────────────────────┐   │
│  │  Product Page                            │   │
│  │  ├── ProductList (useQueryReact Query)│   │
│  │  ├── SearchBar (useStatelocal)        │   │
│  │  └── AddToCart (useCartStoreZustand)  │   │
│  └──────────────────────────────────────────┘   │
└─────────────────────────────────────────────────┘

State breakdown:
  useState:      SearchBar query, FilterBar selections, form inputs
  Context:       Auth user (read everywhere, changes on login/logout)
                 Theme (read everywhere, changes on toggle)
  Zustand:       Cart items (read by CartIcon + CartPage, written by ProductPage)
  React Query:   Stats, revenue, orders, products (all from API)
Implementation Sketchjsx
// ── Context: Auth (changes rarely, needed everywhere) ──
const AuthContext = createContext(null);
function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  // login/logout logic...
  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

// ── Zustand: Cart (changes often, needed in header + product + cart pages) ──
const useCartStore = create((set) => ({
  items: [],
  addItem: (product) => set((s) => ({ items: [...s.items, product] })),
  removeItem: (id) => set((s) => ({ items: s.items.filter(i => i.id !== id) })),
  total: () => 0, // computed in selector
}));

// ── React Query: Products (server state, cached) ──
function useProducts(category) {
  return useQuery({
    queryKey: ["products", category],
    queryFn: () => fetch(`/api/products?cat=${category}`).then(r => r.json()),
    staleTime: 2 * 60 * 1000,
  });
}

// ── Component: ProductPage (composes all patterns) ──
function ProductPage() {
  const [search, setSearch] = useState("");           // local state
  const { user } = useAuth();                          // context
  const addItem = useCartStore((s) => s.addItem);      // zustand
  const { data: products, isLoading } = useProducts(); // react query

  const filtered = products?.filter(p =>
    p.name.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <div>
      <SearchBar value={search} onChange={setSearch} />
      {isLoading ? <Spinner /> : (
        <ProductGrid
          products={filtered}
          onAddToCart={user ? addItem : () => alert("Please login")}
        />
      )}
    </div>
  );
}

Four patterns, one page

The ProductPage uses all four patterns simultaneously — and each is the right tool for its job. useState for the search input (local, ephemeral). Context for auth (global, rarely changes). Zustand for cart (global, changes often). React Query for products (server data, cached). This is what good state architecture looks like.

12

Performance Insights

State management directly impacts rendering performance. The wrong pattern causes unnecessary re-renders that slow down the app.

✓ Done

State Colocation

Keep state as close to where it's used as possible. A search input's state should live in the SearchBar component, not in a global store. Local state never causes re-renders in other components.

✓ Done

Selector Subscriptions

Zustand and Redux let components subscribe to specific slices of state. CartIcon subscribes to items.length, not the entire cart. When item details change, CartIcon doesn't re-render.

→ Could add

Context Splitting

Split large contexts into smaller ones. Instead of one AppContext with theme + auth + locale, use ThemeContext + AuthContext + LocaleContext. A theme change won't re-render auth consumers.

✓ Done

Memoization

Use useMemo for expensive derived state and React.memo for components that receive stable props. Prevents re-computation and re-rendering when parent state changes but child props don't.

Re-render Comparisontext
Scenario: Cart has 10 items. User adds 1 more item.

── Context (no optimization) ──────────────────────
  CartContext value changesALL consumers re-render:
CartIcon re-renders (needs item count)
CartPage re-renders (needs item list)
CheckoutButton re-renders (needs total price)
Header re-renders (just reads cart for badge)
ProductCard re-renders (reads addItem function)
  = 5 re-renders, even though most only need one field

── Zustand (with selectors) ───────────────────────
  Cart store updatesonly subscribed slices trigger:
CartIcon re-renders (subscribed to items.length)
CartPage re-renders (subscribed to items array)
CheckoutButton skipped (subscribed to totalunchanged)
Header skipped (subscribed to items.length — same as CartIcon)
ProductCard skipped (subscribed to addItemstable reference)
  = 2 re-renders, only components whose slice actually changed

The performance hierarchy

From best to worst re-render performance: (1) Local useState — only the component re-renders. (2) Zustand/Redux with selectors — only components whose slice changed re-render. (3) Lifted state — parent + all children re-render. (4) Context — all consumers re-render on any change. Choose accordingly.

13

Common Mistakes

These mistakes are the most common reasons state management becomes painful in real projects.

🔨

Using Redux for everything

Putting form inputs, toggle states, and API data all in Redux. Every state change dispatches an action, goes through reducers, and triggers selectors. Massive overhead for simple state.

Use Redux only for complex global client state. Use useState for local UI state. Use React Query for server data. Most apps need far less Redux than they think.

🌐

Putting server data in client stores

Fetching API data in useEffect, storing it in Redux/Zustand, and manually handling loading, error, caching, and revalidation. Hundreds of lines of code that React Query handles automatically.

Use React Query (TanStack Query) or SWR for all server state. They handle caching, loading states, revalidation, deduplication, and optimistic updates out of the box.

📢

Overusing Context for frequent updates

Putting rapidly changing state (form inputs, real-time data, animations) in Context. Every keystroke re-renders every consumer of that Context, causing visible lag.

Context is for low-frequency state (theme, auth, locale). For frequently changing state shared across components, use Zustand or Redux with selector subscriptions.

🏔️

Lifting all state to the root

Moving every piece of state to the App component 'just in case' another component needs it. The root re-renders on every state change, cascading through the entire tree.

Keep state as close to where it's used as possible (colocation). Only lift state when a sibling actually needs it. Use Context or Zustand for truly global state.

🗄️

One giant global store

A single Redux store with 50 slices, or one Zustand store with every piece of state in the app. Hard to reason about, hard to debug, and every component is coupled to the store shape.

Split stores by domain. Zustand: create separate stores (useCartStore, useAuthStore, useUIStore). Redux: use feature-scoped slices. Keep stores focused and independent.

🔄

Not separating derived state

Storing computed values (filtered list, total price, formatted date) in state and manually keeping them in sync with source data. They inevitably get out of sync.

Derive state on render or with useMemo. filteredUsers = users.filter(...) — computed from source state, always in sync. Never store what you can compute.

14

Interview Questions

These questions test architectural thinking — not library syntax. Strong answers explain trade-offs and match patterns to problems.

Q:What is state management and why does it matter?

A: State management is how you store, update, and share data that drives the UI. It matters because state determines what the user sees — when state is disorganized, the UI becomes unpredictable (stale data, out-of-sync components, unnecessary re-renders). Good state management means each piece of state has a clear owner, a single source of truth, and an efficient update path.

Q:When would you use Context API vs Redux/Zustand?

A: Context is best for low-frequency, app-wide state: theme, auth user, locale, feature flags. It re-renders all consumers on any change, so it's unsuitable for frequently updating state. Redux/Zustand are best for global state that changes often (cart, notifications, complex UI state) because they support selector-based subscriptions — components only re-render when their specific slice changes.

Q:What is the difference between server state and client state?

A: Client state is created and owned by the frontend (form inputs, UI toggles, selections). It's synchronous and always up-to-date. Server state is fetched from an API and cached on the client (user profile, product list). It's asynchronous (loading/error states), can become stale, and needs revalidation. They need different tools: useState/Zustand for client state, React Query/SWR for server state.

Q:Why shouldn't you put API data in Redux?

A: API data is server state with unique concerns: loading states, error handling, caching, staleness, revalidation, deduplication, pagination, and optimistic updates. Redux doesn't handle any of these — you'd write hundreds of lines of boilerplate. React Query handles all of them automatically with a few lines. Redux is for client state (UI state, complex transitions). React Query is for server state (API data).

Q:How do you avoid prop drilling?

A: Four strategies: (1) State colocation — keep state close to where it's used, reducing the need to pass it far. (2) Component composition — pass components as children instead of data as props. (3) Context API — for state needed by many distant components (theme, auth). (4) External stores (Zustand/Redux) — any component can read/write directly without props. Choose based on how many components need the state and how often it changes.

Q:How would you manage state in a large e-commerce app?

A: Layer the patterns: useState for local UI state (form inputs, toggles, dropdowns). Context for auth and theme (global, rarely changes). Zustand for cart state (global, changes often, needed by header + product pages + cart page). React Query for all API data (products, orders, user profile — with caching and revalidation). This gives each type of state the right tool with minimal overhead.

Q:What is state colocation and why is it important?

A: State colocation means keeping state as close to where it's used as possible. A search input's state should live in the SearchBar component, not in a global store. Benefits: (1) fewer re-renders (local state only re-renders one component), (2) easier to understand (state is next to the code that uses it), (3) easier to delete (remove the component, state goes with it). Only move state 'up' or 'out' when another component actually needs it.

Q:Compare Zustand and Redux Toolkit. When would you choose each?

A: Zustand: minimal boilerplate, no Provider wrapper, simple API (create + use hook), great for small-medium apps or when you want a lightweight global store. Redux Toolkit: more structured (slices, actions, reducers), powerful DevTools with time-travel debugging, middleware ecosystem (thunks, sagas), better for large teams that need strict conventions. Choose Zustand for simplicity, Redux for structure and tooling at scale.

15

Cheat Sheet

Quick-reference decision rules for state management.

Quick Revision Cheat Sheet

Default choice: useState — start local, move up only when needed

Two siblings need same state: Lift state to nearest common parent

Complex local transitions: useReducer — state machine in one component

Global + rarely changes: Context API (theme, auth, locale)

Global + changes often: Zustand or Redux (cart, notifications, UI prefs)

API data: React Query / SWR — never put in Redux or Zustand

Context re-render problem: All consumers re-render on any change — split contexts or use Zustand

Zustand vs Redux: Zustand = simple, no Provider. Redux = structured, DevTools, large teams

Server state concerns: Loading, error, caching, staleness, revalidation, deduplication

Client state concerns: Synchronous, always fresh, no caching needed

State colocation: Keep state as close to where it's used as possible

Derived state: Never store what you can compute — use useMemo or compute on render

Selector subscriptions: Zustand/Redux let components subscribe to specific slices — prevents re-render storms

Modern stack: React Query (server) + Zustand (global client) + useState (local)