React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandcontextmigrationcomparison

Context vs Zustand

Recipe

Decide when to use React Context and when to use Zustand. Context works for low-frequency, theme-like state. Zustand excels for high-frequency, multi-consumer state that needs selective re-rendering.

// Context: Good for rarely-changing, provider-scoped state
const ThemeContext = createContext<"light" | "dark">("light");
 
// Zustand: Good for frequently-changing, global state with many consumers
const useCartStore = create<CartStore>((set) => ({
  items: [],
  addItem: (item) => set((s) => ({ items: [...s.items, item] })),
}));

Working Example

// BEFORE: Context-based state (causes re-renders in all consumers)
// context/app-context.tsx
"use client";
 
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
 
interface AppState {
  user: { name: string } | null;
  theme: "light" | "dark";
  notifications: string[];
  sidebarOpen: boolean;
}
 
interface AppContextValue extends AppState {
  setUser: (user: AppState["user"]) => void;
  toggleTheme: () => void;
  addNotification: (msg: string) => void;
  toggleSidebar: () => void;
}
 
const AppContext = createContext<AppContextValue | null>(null);
 
export function AppProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<AppState["user"]>(null);
  const [theme, setTheme] = useState<AppState["theme"]>("light");
  const [notifications, setNotifications] = useState<string[]>([]);
  const [sidebarOpen, setSidebarOpen] = useState(true);
 
  const toggleTheme = useCallback(() => {
    setTheme((t) => (t === "light" ? "dark" : "light"));
  }, []);
 
  const addNotification = useCallback((msg: string) => {
    setNotifications((n) => [...n, msg]);
  }, []);
 
  const toggleSidebar = useCallback(() => {
    setSidebarOpen((s) => !s);
  }, []);
 
  // Problem: new object on every render -> all consumers re-render
  return (
    <AppContext.Provider
      value={{
        user, theme, notifications, sidebarOpen,
        setUser, toggleTheme, addNotification, toggleSidebar,
      }}
    >
      {children}
    </AppContext.Provider>
  );
}
 
export const useApp = () => {
  const ctx = useContext(AppContext);
  if (!ctx) throw new Error("useApp must be inside AppProvider");
  return ctx;
};
// AFTER: Zustand store (selective re-renders, no provider needed)
// stores/app-store.ts
import { create } from "zustand";
 
interface AppStore {
  user: { name: string } | null;
  theme: "light" | "dark";
  notifications: string[];
  sidebarOpen: boolean;
  setUser: (user: AppStore["user"]) => void;
  toggleTheme: () => void;
  addNotification: (msg: string) => void;
  toggleSidebar: () => void;
}
 
export const useAppStore = create<AppStore>((set) => ({
  user: null,
  theme: "light",
  notifications: [],
  sidebarOpen: true,
 
  setUser: (user) => set({ user }),
  toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
  addNotification: (msg) => set((s) => ({ notifications: [...s.notifications, msg] })),
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));
// Component migration
// BEFORE:
function Header() {
  const { user, theme, toggleTheme } = useApp();
  // Re-renders when notifications or sidebarOpen change too
  return <header>...</header>;
}
 
// AFTER:
function Header() {
  const user = useAppStore((s) => s.user);
  const theme = useAppStore((s) => s.theme);
  const toggleTheme = useAppStore((s) => s.toggleTheme);
  // Only re-renders when user or theme actually changes
  return <header>...</header>;
}

Deep Dive

How It Works

  • React Context re-renders every consumer whenever the provider value changes. Even if a consumer only uses theme, it re-renders when notifications changes.
  • Zustand uses internal subscriptions with selectors. Each component subscribes to specific state slices and only re-renders when that slice changes.
  • Context requires a Provider component in the tree. Zustand stores are module-level singletons accessible from anywhere.
  • Context naturally scopes state to a subtree. Zustand is global by default but can be scoped using createStore + useStore with a Context.
  • Zustand state updates are synchronous and batched by React 18+. Context state updates go through React's setState mechanism.

Variations

When Context is still the right choice:

// Theme, locale, or auth status that rarely changes
// and naturally scopes to a subtree
const ThemeContext = createContext<"light" | "dark">("light");
 
// Feature flags per route segment
const FeatureFlagContext = createContext<Record<string, boolean>>({});
 
// Form state scoped to a single form
const FormContext = createContext<FormState | null>(null);

Zustand with Context for SSR scoping:

import { createStore, useStore } from "zustand";
import { createContext, useContext, useRef } from "react";
 
type CounterStore = ReturnType<typeof createCounterStore>;
 
const createCounterStore = (initialCount = 0) =>
  createStore<{ count: number; increment: () => void }>((set) => ({
    count: initialCount,
    increment: () => set((s) => ({ count: s.count + 1 })),
  }));
 
const CounterContext = createContext<CounterStore | null>(null);
 
export function CounterProvider({
  children,
  initialCount,
}: {
  children: React.ReactNode;
  initialCount?: number;
}) {
  const storeRef = useRef<CounterStore>();
  if (!storeRef.current) {
    storeRef.current = createCounterStore(initialCount);
  }
  return (
    <CounterContext.Provider value={storeRef.current}>
      {children}
    </CounterContext.Provider>
  );
}
 
export function useCounterStore<T>(selector: (s: { count: number; increment: () => void }) => T) {
  const store = useContext(CounterContext);
  if (!store) throw new Error("Missing CounterProvider");
  return useStore(store, selector);
}

TypeScript Notes

  • Context types require separate value and action types or a combined interface.
  • Zustand types are simpler since state and actions live in the same interface.
  • Both approaches work with TypeScript. Zustand's selector pattern provides better type narrowing.

Gotchas

  • Context is not "slow." It is appropriate for low-frequency state. The performance problem only appears with many consumers and frequent updates.
  • Splitting Context into multiple providers (one for state, one for actions) can reduce unnecessary re-renders but adds complexity.
  • Zustand stores are singletons. In SSR, all requests share the same store unless you use the Context + createStore pattern.
  • Migrating from Context to Zustand requires removing the Provider wrapper and changing useContext calls to useStore(selector) calls. Do it incrementally.
  • useMemo on the Context value helps but does not solve the fundamental re-render problem when any value changes.
  • Zustand does not integrate with React DevTools natively (use Redux DevTools via the devtools middleware). Context state shows up in React DevTools.

Alternatives

ApproachProsCons
React ContextBuilt-in, no dependencies, tree-scopedRe-renders all consumers on any change
ZustandSelective re-renders, no provider, simple APIGlobal singleton, SSR considerations
JotaiAtomic model, provider-optionalDifferent mental model
Redux ToolkitMature, great DevToolsBoilerplate, steep learning curve
use(Context) + useMemoReduces some re-rendersDoes not solve the core problem

FAQs

Why does React Context re-render all consumers when any value changes?
  • Context provides a single value object to all consumers.
  • When any property in that object changes, React re-renders every component that calls useContext on it.
  • There is no built-in selector mechanism to subscribe to specific fields.
How does Zustand avoid the re-render problem that Context has?
  • Zustand uses internal subscriptions with selector functions.
  • Each component subscribes to a specific state slice and only re-renders when that slice changes.
  • This is fundamentally different from Context's "all or nothing" approach.
When is React Context still the right choice over Zustand?
  • For low-frequency, rarely-changing state: theme, locale, auth status.
  • When state naturally scopes to a subtree (e.g., form state within a form).
  • When you want zero external dependencies.
Does Zustand require a Provider component?
  • No. Zustand stores are module-level singletons accessible from anywhere.
  • Exception: in SSR/Next.js, use Context + createStore to scope stores per request.
How do you migrate a component from Context to Zustand?
// Before (Context):
const { user, theme } = useApp();
 
// After (Zustand):
const user = useAppStore((s) => s.user);
const theme = useAppStore((s) => s.theme);
  • Remove the Provider wrapper and change useContext calls to useStore(selector).
Gotcha: Is React Context actually slow?
  • Context itself is not slow. The performance issue only appears with many consumers and frequent updates.
  • For state that changes rarely (like theme), Context is perfectly fine.
Gotcha: Does wrapping the Context value in useMemo solve the re-render problem?
  • useMemo reduces re-renders caused by the provider re-rendering.
  • But it does not solve the core issue: any change to any property in the value re-renders all consumers.
Can you split Context into multiple providers to reduce re-renders?
  • Yes. One provider for state and one for actions reduces some unnecessary re-renders.
  • But this adds complexity and still does not provide selector-based subscriptions.
How do you scope a Zustand store to a subtree (like Context does naturally)?
const StoreContext = createContext<MyStore | null>(null);
 
function Provider({ children }) {
  const storeRef = useRef(createStore<State>((set) => ({ ... })));
  return (
    <StoreContext.Provider value={storeRef.current}>
      {children}
    </StoreContext.Provider>
  );
}
  • Use createStore (vanilla) + Context + useStore for per-subtree instances.
How do TypeScript types differ between Context and Zustand approaches?
  • Context requires separate value and action types plus a null check in useContext.
  • Zustand combines state and actions in one interface with automatic selector type inference.
  • Zustand's selector pattern provides better type narrowing.
Does Zustand integrate with React DevTools?
  • Not directly. Zustand stores do not appear in React DevTools.
  • Use the devtools middleware to connect to Redux DevTools instead.
  • Context state does appear in React DevTools by default.