Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
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:
ThemeStateContext and ThemeActionsContext are separateuseMemo - action-only consumers never re-rendermemo on leaf components to prevent re-renders from parent rendersuseMemo on the actions object ensures its reference never changes, making the actions context stable.useMemo on 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.use() can read context conditionally (inside if-statements), unlike useContext.| 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) |
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>
);
}createContext<T>(defaultValue) when the context can work without a provider.createContext<T | null>(null) when a provider is required, and check for null in the custom hook.ThemeActions not ThemeActions | null) after the null check.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: Use useMemo to 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 null default + custom hook with throw for required providers.
| 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 |
useMemo(() => actions, []), its reference never changes.const actions = useMemo<ThemeActions>(
() => ({
toggleMode: () => setState((s) => ({ ...s, mode: s.mode === "light" ? "dark" : "light" })),
setAccentColor: (color) => setState((s) => ({ ...s, accentColor: color })),
}),
[]
);use() can be called conditionally (inside if-statements), unlike useContext().use() also works with promises for Suspense-based data fetching.useContext() still works in React 19 but use() is the more flexible alternative.a and b have not changed.useMemo or split into separate contexts.createContext(defaultValue), components work without a provider.createContext<T | null>(null) and throw in the custom hook when context is null.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;
}| null default and check for null in the custom hook.MyState (non-null) after the check.useSyncExternalStore subscribes to an external store and reads a selected slice of state.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>
);
}dispatch is stable (React guarantees it), so dispatch-only consumers never re-render.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥