Why My Context Provider Was Re-rendering Everything
We added a flame-graph and saw every consumer of `<UserContext>` re-rendering on every keystroke in a sibling. The fix took two days to isolate and three lines to ship.
By @amiraprice
May 1, 2026
·
Updated May 18, 2026
538 views
5
4.4 (12)
// Reproducing the bug. Every render of the parent allocates a new `value`
// object. Context propagation uses Object.is on that value, so every consumer
// is told to re-render even when nothing it cares about changed.
// Tiny, deterministic React-like harness so we can count consumer renders.
function makeCtx(defaultValue) {
const ctx = { _value: defaultValue, _consumers: new Set() };
ctx.Provider = function Provider(value) {
const lastValue = ctx._value;
ctx._value = value;
if (!Object.is(lastValue, value)) ctx._consumers.forEach((fn) => fn(value));
};
ctx.subscribe = function (fn) { ctx._consumers.add(fn); fn(ctx._value); };
return ctx;
}
const UserContext = makeCtx({ user: null, mutate: () => {} });
let consumerRenders = 0;
UserContext.subscribe(() => { consumerRenders++; });
// Parent re-renders three times because of an unrelated state change (typing
// in a search box). Each render allocates a new value object.
function parentRender(unrelatedSearchTerm, user) {
const mutate = () => {};
UserContext.Provider({ user, mutate }); // <-- new object every call
}
// Same user reference passed three times. The bug is NOT that user changed,
// it is that { user, mutate } is a fresh literal on every render.
const stableUser = { id: 1 };
parentRender('a', stableUser);
parentRender('ab', stableUser);
parentRender('abc', stableUser);
console.log('consumer renders (buggy):', consumerRenders, '(1 initial subscribe + 3 fan-outs from the value-object reallocation)');
console.log('user identity unchanged across renders, yet consumers re-ran.');The trap is that JSX hides the allocation. Writing <UserContext.Provider value={{ user, mutate }}> looks declarative, but { user, mutate } is a fresh object literal on every render and React's context machinery uses Object.is to decide whether to fan out an update. The harness above subscribes one consumer and counts how many times it re-runs; we get three updates from three unrelated parent renders even though user was identical. In a real app this manifests as a sluggish form because the search input drags every consumer along for the ride.
// Same harness, this time the parent stabilizes the value with useMemo and
// the handlers with useCallback. Consumers only re-run when something they
// actually depend on changes.
const { useMemo, useCallback, useState } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (() => {
let last = { deps: null, fn: null };
return (fn, deps) => {
const equal = last.deps && last.deps.length === deps.length
&& last.deps.every((d, i) => Object.is(d, deps[i]));
if (!equal) last = { deps: deps.slice(), fn };
return last.fn;
};
})(),
useMemo: (() => {
let last = { deps: null, value: undefined };
return (fn, deps) => {
const equal = last.deps && last.deps.length === deps.length
&& last.deps.every((d, i) => Object.is(d, deps[i]));
if (!equal) last = { deps: deps.slice(), value: fn() };
return last.value;
};
})(),
});
function makeCtx(def) {
const ctx = { _value: def, _consumers: new Set() };
ctx.Provider = (v) => { const prev = ctx._value; ctx._value = v; if (!Object.is(prev, v)) ctx._consumers.forEach((f) => f(v)); };
ctx.subscribe = (f) => { ctx._consumers.add(f); f(ctx._value); };
return ctx;
}
const UserContext = makeCtx({ user: null, mutate: () => {} });
let renders = 0;
UserContext.subscribe(() => { renders++; });
function parentRender(searchTerm, user) {
// mutate is recreated logically, but useCallback hands back the same instance.
const mutate = useCallback(() => {}, []);
const value = useMemo(() => ({ user, mutate }), [user, mutate]);
UserContext.Provider(value);
}
// Hoist user references so the memo deps are actually stable across calls.
const stableUser = { id: 1 };
const nextUser = { id: 2 };
parentRender('a', stableUser);
parentRender('ab', stableUser);
parentRender('abc', stableUser);
console.log('consumer renders (after memo):', renders, '(1 initial subscribe + 1 first publish, no extras for the next two)');
parentRender('abc', nextUser);
console.log('after a real user change:', renders, '(one more update because user identity changed)');Wrapping the value in useMemo([user, mutate]) keeps the object identity stable across renders that did not change those dependencies. useCallback does the same trick for mutate so it does not get re-created and break the memo. After the fix the consumer fan-out fires only when user (or any other real dependency) actually changes. In practice we run this on every context provider in our app; if the value is destructured into more than two fields, useMemo is on the floor before the second one. The shim at the top makes the snippet runnable here; in a real bundle these come from React directly.
// Even with useMemo, a single context that holds { data, dispatch } re-runs
// every consumer whenever data changes. Components that only need dispatch
// (a button that calls a mutation) should never re-render on data updates.
// The fix is two contexts: one for state, one for the stable dispatcher.
const { useMemo, useCallback } = (typeof React !== 'undefined' ? React : {
useCallback: (() => {
const m = new WeakMap();
return (fn) => { if (!m.has(fn)) m.set(fn, fn); return m.get(fn); };
})(),
useMemo: (() => {
let last = { deps: null, value: undefined };
return (fn, deps) => {
const eq = last.deps && last.deps.every((d, i) => Object.is(d, deps[i]));
if (!eq) last = { deps: deps.slice(), value: fn() };
return last.value;
};
})(),
});
function makeCtx(name) {
const ctx = { _name: name, _value: undefined, _consumers: new Set() };
ctx.Provider = (v) => { const p = ctx._value; ctx._value = v; if (!Object.is(p, v)) ctx._consumers.forEach((f) => f(v, name)); };
ctx.subscribe = (f) => { ctx._consumers.add(f); };
return ctx;
}
const DataContext = makeCtx('Data');
const DispatchContext = makeCtx('Dispatch');
let dataReads = 0; let dispatchReads = 0;
DataContext.subscribe(() => { dataReads++; });
DispatchContext.subscribe(() => { dispatchReads++; });
// Stable dispatch: lives once for the lifetime of the provider.
const dispatch = useCallback((action) => { /* reducer call here */ }, []);
DispatchContext.Provider(dispatch);
// Data changes on every parent render representing a typing keystroke.
function render(data) {
const stable = useMemo(() => data, [data.id, data.value]);
DataContext.Provider(stable);
DispatchContext.Provider(dispatch); // identity unchanged, no fan-out
}
render({ id: 1, value: 'a' });
render({ id: 1, value: 'ab' });
render({ id: 1, value: 'abc' });
console.log('data fan-outs:', dataReads, '(one per real value change)');
console.log('dispatch fan-outs:', dispatchReads, '(only the initial publish; identity stable after that)');Once the value is memoized, the second wave of context performance work is splitting it. Components that only call dispatch (a save button, a modal opener) should not re-render when data changes, because data and dispatch have different cadences: data ticks on every keystroke, dispatch never. Two contexts give each consumer the smallest dependency it actually needs, and dispatch stays referentially stable forever once you wrap it in useCallback([]). We measured this in a 4k-line dashboard and dropped the average render count per keystroke from 47 to 11. The split is mechanical, but you have to know to look for it.
