Managing State in Large-Scale React Apps: From Context to Zustand

Managing State in Large-Scale React Apps: From Context to Zustand

Architecture6 min readJanuary 18, 2026

A taxonomy of state types and selection heuristics for Context, Zustand, and React Server State in scalable applications.

In large applications, "state" isn't monolithic. It encompasses a portfolio of concerns: component-local values, URL parameters, server-derived data, in-memory caches, persistent preferences, and domain state shared across features.

State Taxonomy

  • Local UI state - input values, toggles, disclosure. Keep inside components.
  • URL state - filters, sort, pagination. Must be shareable and bookmarkable.
  • Server cache - data from APIs/DB. Fetch on the server or use a client cache like TanStack Query.
  • Global client state - user preferences, ephemeral feature flags. Use a lightweight store like Zustand.
  • Persistent state - settings saved to localStorage/IndexedDB.
  • Derived/computed - values computed from other state. Avoid duplication.

Principle: Own state at the smallest reasonable boundary. Lift only when multiple consumers coordinate.

Which Tool When

NeedUseWhy
One component's UIuseStateZero indirection
Cross-cutting UI (theme, toasts)Context (split providers)Rare updates, many readers
Domain selection across routesZustand + selectorsFine-grained subscriptions
Server data list/detailServer ComponentsSmaller bundles, streaming
Client mutations/cachingTanStack QueryStale-while-revalidate, retries
Shareable filtersURL/search paramsDeep links and Back/Forward
Persisted preferencesZustand persist middlewareDurable, selective serialization

Context for Cross-Cutting UI

Context excels for rarely changing values read by many nodes. Avoid putting rapidly updating state in Context since every consumer re-renders.

"use client";
import * as React from "react";
 
type Theme = "light" | "dark";
export const ThemeContext = React.createContext<{
  theme: Theme;
  setTheme(t: Theme): void;
} | null>(null);
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = React.useState<Theme>("light");
  const value = React.useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

Zustand for Global Client State

Zustand provides fine-grained subscriptions. Components re-render only on selected slices:

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
 
type SelState = {
  selectedIds: Set<string>;
  toggle(id: string): void;
  clear(): void;
};
 
export const useSelection = create<SelState>()(
  persist(
    (set) => ({
      selectedIds: new Set<string>(),
      toggle: (id) => set((s) => {
        const next = new Set(s.selectedIds);
        next.has(id) ? next.delete(id) : next.add(id);
        return { selectedIds: next };
      }),
      clear: () => set({ selectedIds: new Set() }),
    }),
    { name: "app-selection", storage: createJSONStorage(() => localStorage) },
  ),
);

Selectors avoid over-rendering:

const count = useSelection((s) => s.selectedIds.size);

React Server State (App Router)

Data fetching in Server Components keeps bundles smaller and allows streaming. Use revalidate/tags for caching. Use Server Actions for mutations, then revalidatePath/tag to refresh.

export const revalidate = 300;
async function getItems() {
  return fetch("https://api.example.com/items", {
    next: { revalidate: 300, tags: ["items"] },
  }).then((r) => r.json());
}
 
export default async function Page() {
  const items = await getItems();
  return <ItemsList initialItems={items} />;
}

URL/Search Params as State

Use search params for list view state (q, sort, page). The App Router passes them to your page. Keep source of truth in the URL:

export default function ItemsPage({
  searchParams,
}: {
  searchParams: { q?: string; page?: string };
}) {
  const q = searchParams.q ?? "";
  const page = Number(searchParams.page ?? 1);
}

Performance Tips

  • Split contexts: ThemeProvider, ToastProvider, not one giant root
  • In Zustand, select minimal state and pass stable callbacks
  • Virtualize long lists
  • Memoize expensive selectors and derived state