Context Patterns — Split, scope, and optimize React context to avoid unnecessary re-renders
Recipe
import { createContext, use, useState, useCallback, type ReactNode } from "react";
// Split context: separate state from dispatch
const CountStateContext = createContext<number>(0);
const CountDispatchContext = createContext<{
increment: () => void;
decrement: () => void;
} | null>(null);
function CountProvider({ children }: { children: ReactNode }) {
const [count, setCount] = useState(0);
const dispatch = useMemo(() => ({
increment: () => setCount((c) => c + 1),
decrement: () => setCount((c) => c - 1),
}), []);
return (
<CountStateContext value={count}>
<CountDispatchContext value={dispatch}>
{children}
</CountDispatchContext>
</CountStateContext>
);
}When to reach for this: When you have context that causes re-renders in components that only need part of the context value. Split state from actions, scope context to subtrees, and use selectors to read only what you need.
Working Example
import {
createContext,
use,
useState,
useMemo,
useCallback,
memo,
type ReactNode,
} from "react";
// --- Split context: Theme state vs actions ---
interface ThemeState {
mode: "light" | "dark";
accentColor: string;
fontSize: number;
}
interface ThemeActions {
toggleMode: () => void;
setAccentColor: (color: string) => void;
setFontSize: (size: number) => void;
}
const ThemeStateContext = createContext<ThemeState>({
mode: "light",
accentColor: "#3b82f6",
fontSize: 16,
});
const ThemeActionsContext = createContext<ThemeActions | null>(null);
function ThemeProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ThemeState>({
mode: "light",
accentColor: "#3b82f6",
fontSize: 16,
});
// Stable actions object — never causes re-renders in action-only consumers
const actions = useMemo<ThemeActions>(
() => ({
toggleMode: () =>
setState((s) => ({
...s,
mode: s.mode === "light" ? "dark" : "light",
})),
setAccentColor: (color) =>
setState((s) => ({ ...s, accentColor: color })),
setFontSize: (size) =>
setState((s) => ({ ...s, fontSize: size })),
}),
[]
);
return (
<ThemeStateContext value={state}>
<ThemeActionsContext value={actions}>
{children}
</ThemeActionsContext>
</ThemeStateContext>
);
}
// Custom hooks with safety checks
function useThemeState() {
return use(ThemeStateContext);
}
function useThemeActions() {
const actions = use(ThemeActionsContext);
if (!actions) throw new Error("useThemeActions must be within ThemeProvider");
return actions;
}
// --- Components demonstrating selective consumption ---
// Only re-renders when theme state changes
const ThemeIndicator = memo(function ThemeIndicator() {
const { mode, accentColor } = useThemeState();
console.log("ThemeIndicator rendered");
return (
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: accentColor }}
/>
<span>{mode} mode</span>
</div>
);
});
// Only re-renders when actions context changes (never, because it's memoized)
const ThemeToggleButton = memo(function ThemeToggleButton() {
const { toggleMode } = useThemeActions();
console.log("ThemeToggleButton rendered");
return (
<button onClick={toggleMode} className="px-3 py-1 border rounded">
Toggle Theme
</button>
);
});
// --- Scoped context pattern ---
interface NotificationContextValue {
notifications: Notification[];
add: (message: string) => void;
dismiss: (id: string) => void;
}
const NotificationContext = createContext<NotificationContextValue | null>(null);
function useNotifications() {
const ctx = use(NotificationContext);
if (!ctx) throw new Error("useNotifications must be within NotificationProvider");
return ctx;
}
interface Notification {
id: string;
message: string;
}
function NotificationProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const add = useCallback((message: string) => {
const id = crypto.randomUUID();
setNotifications((prev) => [...prev, { id, message }]);
setTimeout(() => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, 5000);
}, []);
const dismiss = useCallback((id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
const value = useMemo(
() => ({ notifications, add, dismiss }),
[notifications, add, dismiss]
);
return (
<NotificationContext value={value}>
{children}
</NotificationContext>
);
}
// --- Full app layout ---
function App() {
return (
<ThemeProvider>
<NotificationProvider>
<header className="flex justify-between p-4 border-b">
<ThemeIndicator />
<ThemeToggleButton />
</header>
<main className="p-6">
<ContentArea />
</main>
</NotificationProvider>
</ThemeProvider>
);
}What this demonstrates:
- Split context:
ThemeStateContextandThemeActionsContextare separate - Stable actions via
useMemo— action-only consumers never re-render memoon leaf components to prevent re-renders from parent renders- Scoped notification context with auto-dismiss
- Custom hooks with helpful error messages for missing providers
Deep Dive
How It Works
- React context re-renders every consumer whenever the context value changes (by reference).
- Splitting state and actions into separate contexts means action-only consumers (like buttons) don't re-render when state changes.
useMemoon the actions object ensures its reference never changes, making the actions context stable.- For the state context,
useMemoon the value object is only useful if you have multiple state fields and want to prevent re-renders when unrelated fields change — but since the object is recreated when any field changes, it is most effective with split contexts per domain. - React 19
use()can read context conditionally (inside if-statements), unlikeuseContext.
Parameters & Return Values
| Pattern | What It Solves |
|---|---|
| Split state/actions | Action-only consumers (buttons, forms) don't re-render on state changes |
| Memoized value object | Prevents re-renders when the object reference would change but contents are the same |
| Scoped provider | Context only available to subtree that needs it |
| Custom hook with error | Catches missing provider bugs at development time |
| Default value on createContext | Allows using context without a provider (useful for theme defaults) |
Variations
Selector pattern with external store — subscribe to just the piece you need:
import { useSyncExternalStore } from "react";
// Using useSyncExternalStore for selector-based reads
function useStoreSelector<T, S>(store: Store<T>, selector: (state: T) => S): S {
return useSyncExternalStore(
store.subscribe,
() => selector(store.getSnapshot()),
() => selector(store.getServerSnapshot())
);
}
// Only re-renders when `user.name` changes
function UserName() {
const name = useStoreSelector(appStore, (s) => s.user.name);
return <span>{name}</span>;
}Context with reducer — for complex state transitions:
const TodoDispatchContext = createContext<React.Dispatch<TodoAction> | null>(null);
const TodoStateContext = createContext<TodoState>({ items: [] });
function TodoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, { items: [] });
return (
<TodoStateContext value={state}>
<TodoDispatchContext value={dispatch}>
{children}
</TodoDispatchContext>
</TodoStateContext>
);
}TypeScript Notes
- Provide a sensible default to
createContext<T>(defaultValue)when the context can work without a provider. - Use
createContext<T | null>(null)when a provider is required, and check for null in the custom hook. - Type the custom hook return as non-null (
ThemeActionsnotThemeActions | null) after the null check. - Export context types so consumers can type their own abstractions.
Gotchas
-
Single context with mixed state and actions — Every state change re-renders every consumer, even those that only call actions. Fix: Split into separate state and action contexts.
-
Unstable context value — Creating a new object literal in the provider's render (
value={{ a, b }}) causes every consumer to re-render. Fix: UseuseMemoto stabilize the value reference. -
Provider too high in the tree — Placing a frequently-changing provider at the app root re-renders the entire tree of consumers. Fix: Scope providers to the smallest subtree that needs them.
-
Over-splitting context — Creating dozens of tiny contexts adds complexity and provider nesting. Fix: Split by update frequency (things that change together should live together). Use Zustand or Jotai for fine-grained subscriptions.
-
Default context values hiding bugs — A meaningful default value means the context works without a provider, which can mask a missing provider. Fix: Use
nulldefault + custom hook with throw for required providers.
Alternatives
| Approach | Trade-off |
|---|---|
| Split context | Zero dependencies; manual splitting effort |
| Zustand | Automatic selectors, no providers; extra dependency |
| Jotai | Atomic state, fine-grained re-renders; different mental model |
| Redux + useSelector | Mature ecosystem, time-travel debug; boilerplate |
useSyncExternalStore | Works with any external store; lower-level API |
| Signals (future) | Fine-grained reactivity; not yet in React |
FAQs
Why does putting state and actions in a single context cause performance problems?
- Every state change re-renders every consumer, even those that only call actions (like buttons).
- Consumers that never read state still re-render because the context value reference changes.
- Fix: split into separate state and action contexts.
How does splitting state and actions into separate contexts help performance?
- Action-only consumers (buttons, forms) subscribe only to the actions context.
- Since the actions object is memoized with
useMemo(() => actions, []), its reference never changes. - Action-only consumers never re-render when state changes.
What does useMemo on the actions object accomplish in a context provider?
const actions = useMemo<ThemeActions>(
() => ({
toggleMode: () => setState((s) => ({ ...s, mode: s.mode === "light" ? "dark" : "light" })),
setAccentColor: (color) => setState((s) => ({ ...s, accentColor: color })),
}),
[]
);- It creates a stable reference that never changes across renders.
- Consumers of the actions context never re-render because the reference stays the same.
When should you scope a context provider to a subtree instead of the app root?
- When only a portion of the app needs the context (e.g., a notification system for one page).
- When the context value changes frequently and would cause re-renders in unrelated components.
- Scoping reduces the blast radius of context updates.
How does use() in React 19 differ from useContext()?
use()can be called conditionally (inside if-statements), unlikeuseContext().use()also works with promises for Suspense-based data fetching.useContext()still works in React 19 butuse()is the more flexible alternative.
Gotcha: Why does value={{ a, b }} in a provider cause unnecessary re-renders?
- Creating a new object literal in the render creates a new reference every render.
- React checks context value by reference, so every consumer re-renders even if
aandbhave not changed. - Fix: memoize the value with
useMemoor split into separate contexts.
Gotcha: How can a default context value hide a missing provider bug?
- If you provide a meaningful default to
createContext(defaultValue), components work without a provider. - This can mask a missing provider in the tree, leading to subtle bugs where components use stale defaults.
- Fix: use
createContext<T | null>(null)and throw in the custom hook when context is null.
How do you type a context that requires a provider in TypeScript?
const MyContext = createContext<MyState | null>(null);
function useMyContext(): MyState {
const ctx = use(MyContext);
if (!ctx) throw new Error("useMyContext must be within MyProvider");
return ctx;
}- Use
| nulldefault and check for null in the custom hook. - The hook return type is
MyState(non-null) after the check.
What is the selector pattern with useSyncExternalStore and when is it useful?
useSyncExternalStoresubscribes to an external store and reads a selected slice of state.- Only the selected value triggers re-renders, giving fine-grained subscription without context splitting.
- Useful when you need selector-based reads without adopting a full state management library.
How do you combine context with useReducer for complex state transitions?
const TodoStateContext = createContext<TodoState>({ items: [] });
const TodoDispatchContext = createContext<React.Dispatch<TodoAction> | null>(null);
function TodoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(todoReducer, { items: [] });
return (
<TodoStateContext value={state}>
<TodoDispatchContext value={dispatch}>{children}</TodoDispatchContext>
</TodoStateContext>
);
}- Split state and dispatch into separate contexts.
dispatchis stable (React guarantees it), so dispatch-only consumers never re-render.
When should you use Zustand or Jotai instead of React context?
- When you need fine-grained subscriptions (select a single field without re-rendering on unrelated changes).
- When you have many frequently-changing values that would require excessive context splitting.
- When you want a simpler API without provider nesting.
Related
- Performance — Memoization and re-render optimization techniques
- Compound Components — Context is the backbone of compound components
- Composition — Composition reduces the need for context in many cases