In a React app where one context provides both the state and the function that updates it, why do teams split it into two separate contexts — one for the state and one for the updater — and what does that buy?
answer
- read half and write half
- dispatch identity never changes
- write-only consumers stop being notified
- no memoization needed for either value
- two providers is the cost
basics
~20 sComponents that only trigger updates do not need the state. Putting the updater in its own context gives it a value that never changes, so those write-only components stop re-rendering every time the state changes.
solid answer
~50 sA context notifies every reader when its value changes, and it has no way to say "I only care about the write half". If state and updater share one context, a button that only calls `dispatch` re-renders on every state change, forever, for nothing. Splitting them into two providers fixes that: the updater context's value is `dispatch` from `useReducer` — or a `useCallback`-stabilised setter — which never changes identity, so its consumers are never notified. The state context still notifies its readers, which is correct because they display the state. The cost is two contexts, two providers and two hooks to maintain, so it earns its keep when the state changes often and the write-only consumers are numerous or expensive — a form field list, a deeply nested toolbar, a virtualised grid of action buttons.
code
javascript · 20 linesimport { createContext, useContext, useReducer } from 'react';
const CountStateContext = createContext(0);
const CountDispatchContext = createContext(() => {});
function reducer(count, action) {
return action.type === 'inc' ? count + 1 : count;
}
export function CountProvider({ children }) {
const [count, dispatch] = useReducer(reducer, 0);
return (
<CountStateContext value={count}>
<CountDispatchContext value={dispatch}>{children}</CountDispatchContext>
</CountStateContext>
);
}
export const useCount = () => useContext(CountStateContext);
export const useCountDispatch = () => useContext(CountDispatchContext);go deeper
Know that a component which only triggers an update does not need to read the state, and that giving it a separate context keeps it out of the update path.
Explain why the write context's value is stable — dispatch and useState setters keep their identity — and why that means React never schedules those consumers.
Justify the split with a measurement and say what it does not fix: components re-rendering from their parent are unaffected, and readers of the state half still re-render as they should.
Treat it as a boundary decision: two contexts encode read/write separation in the type system and the import graph, and that clarity may matter more than the renders it saves — or may not be worth the ceremony at your scale.
## The problem it solves The usual shape of a context-based store is one object carrying both halves: ```jsx const value = useMemo(() => ({ todos, dispatch }), [todos]); return <TodoContext value={value}>{children}</TodoContext>; ``` This is correctly memoized — the value only changes when `todos` changes. But now consider two kinds of consumer: - `<TodoList>` reads `todos` and renders them. It *must* re-render when `todos` changes. - `<AddTodoButton>` reads only `dispatch`. It renders a button. It never displays state. Both read the same context, so both are notified on every state change. The button re-renders on every keystroke that touches the store, for a value that did not change from its point of view. Multiply that across a toolbar, a row of per-item action buttons, or a form of thirty inputs each holding a `dispatch`-bound handler, and the waste is real. Context gives you no way to express "notify me only about the parts I read", so you express it structurally instead: **make the parts you read into separate contexts.** ## The shape of the fix ```jsx const TodoStateContext = createContext(null); const TodoDispatchContext = createContext(null); function TodoProvider({ children }) { const [todos, dispatch] = useReducer(todoReducer, []); return ( <TodoStateContext value={todos}> <TodoDispatchContext value={dispatch}>{children}</TodoDispatchContext> </TodoStateContext> ); } ``` Notice what disappeared: there is no `useMemo` anywhere. `todos` is the reducer's state object, already stable between updates, and `dispatch` is stable by construction — React guarantees the same `dispatch` identity for the life of the component. Two single-value contexts sidestep the whole memoization problem, which is a real secondary benefit: there is no dependency array to get wrong. The write-only consumer now reads `TodoDispatchContext`, whose value is `Object.is`-equal on every render forever, so React never schedules it for a context update. It re-renders only if its own props or state change. ## Doing the same without a reducer With `useState` the updater is `setTodos`, which React also guarantees to be stable, so the same split works directly. What is *not* stable is a hand-written wrapper: ```jsx // unstable — new function every render, so the dispatch context changes every render const api = { add: (t) => setTodos((prev) => [...prev, t]) }; ``` If your write context exposes several named actions rather than a raw setter, memoize the whole action object and stabilise each function: ```jsx const add = useCallback((t) => setTodos((prev) => [...prev, t]), []); const clear = useCallback(() => setTodos([]), []); const actions = useMemo(() => ({ add, clear }), [add, clear]); ``` Because each updater uses the functional form of the setter, none of them needs the current state as a dependency, so all of them can have empty dependency arrays and the action object is stable for the lifetime of the provider. That functional-update discipline is what makes the split possible at all — an action written as `() => setTodos([...todos, t])` would depend on `todos` and re-create the whole action object on every state change, collapsing the benefit. ## Ergonomics Expose a hook per half so consumers state their intent, and so you can add the throw-if-missing check in one place: ```jsx export const useTodos = () => useContext(TodoStateContext); export const useTodoDispatch = () => useContext(TodoDispatchContext); ``` A component that calls `useTodoDispatch()` alone is now visibly write-only, and reviewers can see at a glance that adding a `useTodos()` call to it changes its render profile. ## When it is not worth it This is a targeted fix, not a default architecture. Two contexts mean two providers, two hooks, and two things to import; a small app with a handful of consumers and infrequent updates gains nothing measurable and loses some clarity. Reach for it when you can point at the cost: state that updates on every keystroke or every animation frame, or a wide set of consumers that only dispatch. And measure with the Profiler afterwards — the split removes context-driven renders, but if the write-only components were re-rendering because their parent re-rendered, this changes nothing at all.
- What makes the dispatch context's value stable enough that its consumers are never notified?React guarantees that the `dispatch` returned by `useReducer` — and the setter from `useState` — keeps the same identity for the life of the component. Passing it directly as the context value means the provider always hands React an `Object.is`-equal value, so the change check never fires and no consumer is scheduled.
- Your write context exposes named actions rather than a raw dispatch. What must you get right so the split still works?Every action needs a stable identity and the containing object needs memoizing. Write each updater with the functional form — `setState(prev => ...)` — so it does not close over current state and can take an empty dependency array in `useCallback`. Then `useMemo` the action object. If any action depends on the state, the object changes on every update and the split stops helping.
- Would you split a context this way by default in a new codebase?No. It is a fix for a measured cost: frequent updates plus many write-only consumers. In a small provider with a few readers it adds two files' worth of ceremony for renders nobody would notice. Start with one context, and split when the Profiler shows write-only components rendering on state changes.
saying these in an interview costs you the question
- Splits every context by reflex without measuring
- Thinks the split removes re-renders for components that read state
- Wraps dispatch in useCallback believing it is unstable
- Writes actions that close over current state, breaking stability
- Claims splitting gives context selector-like behaviour for state readers