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 whennotificationschanges. - 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+useStorewith 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
useContextcalls touseStore(selector)calls. Do it incrementally. useMemoon 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
| Approach | Pros | Cons |
|---|---|---|
| React Context | Built-in, no dependencies, tree-scoped | Re-renders all consumers on any change |
| Zustand | Selective re-renders, no provider, simple API | Global singleton, SSR considerations |
| Jotai | Atomic model, provider-optional | Different mental model |
| Redux Toolkit | Mature, great DevTools | Boilerplate, steep learning curve |
| use(Context) + useMemo | Reduces some re-renders | Does 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
useContexton 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 +
createStoreto 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
useContextcalls touseStore(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?
useMemoreduces 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 +useStorefor per-subtree instances.
How do TypeScript types differ between Context and Zustand approaches?
- Context requires separate value and action types plus a
nullcheck inuseContext. - 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
devtoolsmiddleware to connect to Redux DevTools instead. - Context state does appear in React DevTools by default.