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.
| Approach | Granularity | Best for |
|---|---|---|
| Context | All-or-nothing per value | Theme, locale, auth session |
| Split contexts | Per-concern subscription | Separate state from dispatch |
useMemo value | Stable reference when unchanged | Preventing unnecessary identity churn |
| Zustand + selectors | Per-slice subscribe | Fast-changing shared state |
useSyncExternalStore | Snapshot-consistent reads | Custom 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 duplicationComposition 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:
memoon 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 frameuseState 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 valueManual useEffect subscribe + setState loses sync-read guarantees.
Interview:
useMutableSourceis deprecated —useSyncExternalStorereplaced 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 onlySSR 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
getServerSnapshotin 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
memodoes 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