Topics
v5.0.0
On this page · sections

Context & External Stores

React Context propagates values down the tree, but every consumer re-renders when the provider’s value identity changes. External stores need useSyncExternalStore to stay tear-free under concurrent rendering.

ApproachGranularityBest for
ContextAll-or-nothing per valueTheme, locale, auth session
Split contextsPer-concern subscriptionSeparate state from dispatch
useMemo valueStable reference when unchangedPreventing unnecessary identity churn
Zustand + selectorsPer-slice subscribeFast-changing shared state
useSyncExternalStoreSnapshot-consistent readsCustom stores, concurrent safety

How Context Triggers Re-renders

Definition

When a provider’s value changes, every useContext consumer of that context re-renders — regardless of depth and regardless of which field they read.

'use client'; import { createContext, useContext, useState } from 'react'; type ThemeCtx = { theme: 'light' | 'dark'; toggle: () => void }; const ThemeContext = createContext<ThemeCtx | null>(null); function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState<'light' | 'dark'>('light'); const value = { theme, toggle: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')), }; return ( <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> ); } function ThemedButton() { const ctx = useContext(ThemeContext); return ( <button type="button" onClick={ctx?.toggle}> {ctx?.theme} </button> ); }

Any state change in ThemeProvider recreates value → ThemedButton re-renders even if theme did not change.

Only components that called useContext for that context re-render. Non-consumers are unaffected — unless their parent re-rendered them normally.

Interview: Context value is compared by reference. Inline { theme, toggle } is a new object every render — the classic bug.

Stabilizing Provider Values

Definition

const value = useMemo( () => ({ theme, toggle }), [theme, toggle], ); return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;

toggle should be wrapped in useCallback if it is recreated each render. useReducer’s dispatch is stable by default.

useMemo on a single context value still re-renders all consumers when any field in the value changes. For field-level granularity, split contexts or use a selector store.

Interview: Stabilizing the reference prevents unnecessary re-renders when data did not change. It does not fix re-renders when data did change.

State / Dispatch Split

Definition

Separate frequently-changing state from stable dispatch functions:

'use client'; import { createContext, useContext, useReducer } from 'react'; type CountState = { count: number }; type CountAction = { type: 'inc' } | { type: 'dec' }; const CountStateContext = createContext<CountState | null>(null); const CountDispatchContext = createContext<React.Dispatch<CountAction> | null>(null); function countReducer(state: CountState, action: CountAction): CountState { switch (action.type) { case 'inc': return { count: state.count + 1 }; case 'dec': return { count: state.count - 1 }; default: return state; } } function CountProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = useReducer(countReducer, { count: 0 }); return ( <CountStateContext.Provider value={state}> <CountDispatchContext.Provider value={dispatch}> {children} </CountDispatchContext.Provider> </CountStateContext.Provider> ); }

Display components read state context. Action-only buttons read dispatch context — dispatch identity is stable, so buttons skip re-renders on count change.

Interview: Split by update frequency and concern. One mega-context with 20 fields guarantees wasted renders.

Context vs Prop Drilling

Definition

Prop drilling OK → 2–3 levels, stable props, clear ownership Context OK → Cross-cutting data many distant leaves need → Theme, locale, auth, feature flags → Moderate update rate Wrong for Context → High-frequency updates (scroll, mouse position, websocket ticks) → Large mutable app state without selectors → Server cache data — use React Query, not Context duplication

Composition alternative

Parent that holds user renders <Page user={user} /> as child — intermediate layout does not need user in its prop interface.

Interview: Context is not a replacement for Redux at scale — built-in Context has no granular subscribe.

Context and React.memo

Definition

React.memo does not block context-driven re-renders. When context value identity changes, the consumer re-renders regardless of props.

const HeavyList = memo(function HeavyList({ rows }: { rows: string[] }) { return <ul>{rows.map((r) => <li key={r}>{r}</li>)}</ul>; }); function Shell() { const { density } = useContext(SettingsContext); return ( <div data-density={density}> <HeavyList rows={STABLE_ROWS} /> </div> ); }

When density toggles, Shell re-renders and HeavyList re-renders too — even though rows is unchanged. Optimize by splitting context, extracting heavy siblings outside the consumer wrapper, or using selector stores.

Interview: memo on a context consumer is wasted overhead — context bypasses props comparison.

Selectors and External Stores

Definition

When many components need slices of fast-changing state, use a store with selectors:

'use client'; import { create } from 'zustand'; const useTickStore = create<{ ticks: number; label: string; inc: () => void }>((set) => ({ ticks: 0, label: 'counter', inc: () => set((s) => ({ ticks: s.ticks + 1 })), })); function TickLabel() { const label = useTickStore((s) => s.label); return <span>{label}</span>; } function TickCount() { const ticks = useTickStore((s) => s.ticks); return <span>{ticks}</span>; }

Increment ticks — only TickCount re-renders. Zustand, Redux 8+, and React Query use useSyncExternalStore internally.

Interview: Server cache belongs in React Query. Context is for client-owned cross-cutting session data.

What Tearing Is

Definition

Tearing is when different parts of the UI show inconsistent snapshots of the same external data during one screen frame — a concurrent rendering bug with mutable external stores.

'use client'; // Anti-pattern — mutable external store read in render let externalCount = 0; function BadCounter() { return <span>Count: {externalCount}</span>; }
Render pass A reads store = 5 → interrupted → tickStore mutates to 6 → Render pass B reads store = 6 → sibling A still shows 5, sibling B shows 6 in one frame

useState does not tear — React state is snapshot-consistent within a render pass. Trading dashboards and live feeds are classic domains.

Interview: Context updates trigger re-renders — a different problem from reading a mutating external variable mid-render.

useSyncExternalStore

Definition

'use client'; import { useSyncExternalStore } from 'react'; function createExternalStore(initial: number) { let value = initial; const listeners = new Set<() => void>(); return { subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); }, getSnapshot() { return value; }, setValue(next: number) { if (next === value) return; value = next; listeners.forEach((l) => l()); }, }; } const store = createExternalStore(0); function Counter() { const count = useSyncExternalStore( store.subscribe, store.getSnapshot, () => 0, // getServerSnapshot ); return ( <button type="button" onClick={() => store.setValue(count + 1)}> {count} </button> ); }
subscribe(onStoreChange) → register listener; return unsubscribe getSnapshot() → immutable snapshot for this render getServerSnapshot() → SSR-safe initial value

Manual useEffect subscribe + setState loses sync-read guarantees.

Interview: useMutableSource is deprecated — useSyncExternalStore replaced it.

subscribe and getSnapshot Contract

Definition

subscribe must call the callback synchronously after any change affecting getSnapshot. getSnapshot must return consistent immutable data — same reference if nothing changed. Cache the snapshot reference on write, not per render.

new object from getSnapshot every call → infinite re-render async subscribe notification → stale UI between mutation and render getSnapshot calling network API → sync read only

SSR and Hydration

Definition

Server render must call getServerSnapshot — static or per-request value. Never window or client-only store on server.

'use client'; import { createContext, useContext, useSyncExternalStore, type ReactNode } from 'react'; type Store = { subscribe: (l: () => void) => () => void; getSnapshot: () => number; getServerSnapshot: () => number; }; const StoreContext = createContext<Store | null>(null); function useCounterStore() { const store = useContext(StoreContext); if (!store) throw new Error('Missing StoreProvider'); return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot); }

No module global on server — one shared store leaks state between SSR requests. RSC passes initialState to client StoreProvider boundary.

Interview: Skipping getServerSnapshot in Next.js causes hydration mismatch.

Senior Interview Summary

Context value

Reference equality. New object → all consumers re-render.

State/dispatch split

Display reads state; buttons read stable dispatch from useReducer.

memo + context

memo does NOT block context updates. Split context or use selectors.

Tearing

Siblings show different external snapshots in one frame during concurrent render.

useSyncExternalStore

subscribe + getSnapshot + getServerSnapshot. Zustand/Redux use it internally.

Best mental model

Context → "Broadcast one value to many consumers." Reference → "Same object? Skip. New object? Everyone re-renders." Split → "Separate what changes from what stays stable." Selectors → "Subscribe to a slice, not the whole store." USS → "Snapshot-consistent external reads."

Practical rule

Theme / locale / auth identity → Context with stable value Cart / notifications (fast) → Zustand with selectors Server-fetched entities → React Query, not Context Custom store in React → mandate useSyncExternalStore for reads Mega-context with 20 fields → decompose by update frequency

Last updated on