Inside React Context: Why It Re-renders Everything (And How Redux Avoids It)
Who this is for: React developers who already use useContext, useState, and useSelector comfortably, but have never quite gotten a straight answer to “wait, why did that component just re-render?”
By the end of this article you won’t just know how to use Context — you’ll be able to predict, before you even open React DevTools, exactly which components will re-render when a Context value changes, and why. That intuition is the actual goal here; the API surface of useContext is five minutes of reading, but the mental model underneath it is what separates "I copied this from the docs" from "I know why this is slow."
1. The problem Context solves
Before Context existed (and still, any time you don’t use it), data in React flows one way: down, through props. If a component eleven levels deep needs the current user, every one of those eleven components has to accept and forward a user prop it never actually uses itself.
function App({ user }) {
return <Layout user={user} />;
}
function Layout({ user }) {
return <Sidebar user={user} />;
}
function Sidebar({ user }) {
return <Profile user={user} />;
}
function Profile({ user }) {
return <Avatar user={user} />; // finally, someone uses it
}This is called prop drilling. It’s not wrong, exactly — it’s explicit and easy to trace — but it doesn’t scale. Every intermediate component becomes coupled to data it has no business knowing about, and adding a new deeply nested consumer means touching every component on the path to it.
Analogy: imagine passing a sealed envelope through a line of ten people to reach the eleventh, and every one of those ten people has to accept the envelope, hold it, and hand it to the next person — even though only the last person opens it. Context is a pneumatic tube that goes straight from sender to intended recipients, skipping the line entirely.
Context solves exactly this problem, and only this problem: it lets a component deep in the tree read a value without every component in between having to relay it. That’s it. It is not a state management system, it doesn’t know about actions or reducers or middleware — it’s a tree-scoped, implicit “skip prop drilling” mechanism. Keep that framing in your head; most of the confusion later in this article (and most Context-related bugs in real apps) comes from treating Context as more than that.
2. How Context works, mechanically
Three pieces:
const ThemeContext = React.createContext(defaultValue);
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
const theme = React.useContext(ThemeContext); // "dark"
return <div className={theme}>...</div>;
}createContext(defaultValue)creates a Context object — think of it as a labeled channel. ThedefaultValueis only used if a component reads the context with no matchingProviderabove it in the tree.<Context.Provider value={...}>doesn't render anything itself in the
DOM sense — it's a signal to React: "for everything below me in this
subtree, the current value on this channel isvalue."useContext(Context)(the modern replacement for<Context.Consumer>
render props) asks React: "what's the current value of this channel, as seen from my position in the tree right now?"
The important word above is position. Context resolution isn’t based on
where a component was defined in your source file, or who rendered it —
it’s based on where it sits in the actual component tree at render time,
relative to the nearest enclosing Provider for that specific Context object. If you nest two ThemeContext.Providers, an inner consumer sees the closer one, exactly like variable scoping in nested blocks — except the "blocks" here are JSX trees, not { } braces.
3. What useContext actually stores
This is where we leave “how to use it” and start on “how it works.” What follows describes React’s real (simplified) internal design — not an analogy.
Every component instance in React is represented internally by a data
structure called a Fiber. A Fiber holds a component’s hooks, its pending
work, and — relevant here — a dependencies field: a linked list of every Context that component has read via useContext during its most recent render, along with a marker of what lanes (React's internal priority/batching unit) are relevant to it.
Conceptually:
FiberNode {
type: Toolbar,
memoizedState: [...hooks...],
dependencies: {
lanes: ...,
firstContext: {
context: ThemeContext,
next: null // linked list — could point to another context if this
// component reads multiple contexts
}
}
}So when Toolbar calls useContext(ThemeContext), two things happen in the
same instant:
- React returns the current value stored on
ThemeContextfor this position in the tree (resolved by walking up to the nearest Provider, conceptually — in practice React maintains this as it walks down during render, pushing and popping values as it enters and exits Providers). - React records, on
Toolbar's Fiber, that it depends onThemeContext.
That second step is the subscription. It’s not a callback you registered by hand (like store.subscribe(fn) in Redux) — it's implicit bookkeeping React does automatically every time useContext runs during render.
4. How React tracks Context dependencies
Zoom out from a single component to the whole subtree under a Provider. As React renders each descendant, any component that calls useContext(SomeCtx) gets tagged, on its own Fiber, as a dependent of SomeCtx — as described above. Components that never call useContext for that Context object simply have no entry for it. Nothing else happens for them; they are invisible to that Context entirely.
This produces something like a scattered, implicit subscriber list spread across the Fiber tree — not a single central list of subscribers (unlike Redux’s store, which we’ll get to), but a property of each individual Fiber: “do I, personally, depend on this Context object?”
Conceptual vs. actual: the “linked list on the Fiber” description above is genuinely how React does this — it’s simplified (I’m not describing lane bitmasks or the exact push/pop stack mechanics during Fiber traversal) but not invented. Section 13 has intentionally-invented pseudo-code to make the behavior easier to reason about; don’t confuse the two.
5. What happens when the Provider’s value changes
Say Toolbar's ancestor re-renders and passes a new value to
ThemeContext.Provider. Here's the sequence:
- The Provider re-renders with the new
value. - React compares the new
valueto the previousvalueusingObject.is(practically identical to===, more on the distinction in Section 8). - If they’re
Object.is-equal: nothing further happens for this Context React doesn't walk the subtree looking for consumers at all. - If they’re not equal: React walks the subtree rooted at this Provider, and for every Fiber it finds whose
dependencieslist includes this Context, it schedules that Fiber for re-render — even if that Fiber's props haven't changed, even if it's wrapped inReact.memo.
This walk can, as an internal optimization, skip over intermediate subtrees that contain no matching dependency and have no other pending work — so it’s not literally “re-render the entire app,” but it is “re-render literally every component that reads this Context, unconditionally,” which for a frequently-changing, widely-read Context can amount to almost the same thing in practice.
6. Why reference comparison, not deep comparison
You might reasonably ask: why doesn’t React just deep-compare the old and new value objects, and skip the update if they're "equal enough"? Two reasons:
Cost. Deep equality is O(n) in the size of the object graph, and has to run on every single render of the Provider — for a large or deeply nested value, that's a real, recurring cost paid whether or not anything actually changed. Reference comparison with Object.is is O(1), always.
Ambiguity. What does “equal” even mean for a value that contains a function, a Date, a Map, a class instance, or a circular reference? JavaScript has no single correct answer, and different deep-equal implementations disagree with each other on edge cases. Reference equality has exactly one unambiguous definition: same object in memory, or not.
React’s whole rendering model already leans on this trade-off — it’s the same reason useMemo, useEffect dependency arrays, and React.memo's default shallow-props check all use reference/shallow equality instead of deep equality. Context is consistent with that, not an exception to it.
7. Why every consumer re-renders, even for one field
Here’s the crux of the whole article. Say your Context value is:
const value = { user, theme, notifications };and a component does:
function ThemeToggle() {
const { theme } = useContext(AppContext);
return <button>{theme}</button>;
}ThemeToggle only reads theme. But from React's point of view, that's invisible — useContext(AppContext) returns the entire object, and what you do with it afterwards (destructuring one field) is just ordinary JavaScript that React's rendering engine has no visibility into. React doesn't do static analysis on your function body to figure out "this component only touches .theme." The subscription recorded in Section 3 is per Context object, not per-field. As far as React's dependency tracking is concerned,ThemeToggle depends on the whole AppContext value, full stop.
So when value gets a new reference — for any reason, including a fieldThemeToggle never reads — React can't tell the difference between "the part you care about changed" and "some other part changed." It only knows "the object you're subscribed to has a new reference," and that's sufficient to re-render you.
8. Object identity, reference equality, and Object.is
A quick, precise grounding, since everything above depends on this:
const a = { x: 1 };
const b = { x: 1 };
const c = a;a === b; // false — different objects, same shape
a === c; // true — same object in memory
Object.is(a, b); // false — same rule as ===, for objectsObject.is behaves like === for objects (comparing identity, not shape),
but differs from === in exactly two edge cases involving primitives:
NaN === NaN; // false
Object.is(NaN, NaN); // true0 === -0; // true
Object.is(0, -0); // falseReact uses Object.is (not ===) for these comparisons specifically to get the correct behavior for NaN, which shows up more often than you'd expect in real state (e.g. Number("abc")). For objects and arrays — which is what Context values usually are — the practical behavior is identical to ===: same reference, or not. There is no shape-checking, no "same keys and values," involved anywhere in this comparison.
9. The multi-value Context problem, demonstrated
Let’s make Section 7 concrete with runnable code.
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState({ name: "Mayank" });
const [theme, setTheme] = useState("dark");
const [notifications, setNotifications] = useState([]);
// A NEW object, every single render of AppProvider:
const value = { user, theme, notifications, setNotifications };
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
function UserBadge() {
const { user } = useContext(AppContext);
console.log("UserBadge rendered");
return <span>{user.name}</span>;
}
function ThemeToggle() {
const { theme } = useContext(AppContext);
console.log("ThemeToggle rendered");
return <button>{theme}</button>;
}
function NotificationBell() {
const { notifications, setNotifications } = useContext(AppContext);
console.log("NotificationBell rendered");
return (
<button onClick={() => setNotifications((n) => [...n, "new!"])}>
🔔 {notifications.length}
</button>
);
}Click the notification bell. setNotifications triggers a state update insideAppProvider, so AppProvider re-renders. On that re-render, the line const value = { user, theme, notifications, setNotifications } executes again and produces a brand-new object literal — even though user and theme themselves didn't change at all.
Console output after one click:
UserBadge rendered
ThemeToggle rendered
NotificationBell renderedAll three. Not just NotificationBell. UserBadge and ThemeToggle re-ran their entire render function for data that, from their perspective, is completely unchanged. This is exactly the mechanism from Section 5 and 7 in action: value got a new reference, so every consumer of AppContext is scheduled for re-render, regardless of which slice they actually read.
Try it yourself: wrap
UserBadgeinReact.memoand click the bell
again. It still re-renders.React.memoonly short-circuits re-renders
triggered by a parent re-rendering with the same props — it has no effect
on re-renders triggered by a Context this component reads directly, because that's not a "props" update at all from React's perspective.
10. Why useMemo doesn't fix this
The instinctive fix is to memoize the value object:
const value = useMemo(
() => ({ user, theme, notifications, setNotifications }),
[user, theme, notifications, setNotifications]
);This helps with a different problem than the one you probably think it solves. useMemo returns the same reference across renders only when none of the values in its dependency array changed since the last render. So:
- If
AppProviderre-renders for a reason unrelated touser,theme,
ornotifications— say, a sibling prop changed, or a parent re-rendered
for its own reasons —useMemocorrectly returns the previousvalueobject, and Context consumers correctly skip re-rendering. This is a real win, and worth doing. - But if
notificationsdid change — which is exactly the scenario in
Section 9 — thennotificationsis in the dependency array, the dependency array comparison fails,useMemocomputes a new object, and every consumer re-renders again.useMemocannot prevent this, because the value genuinely did change; that's not a false-positive re-render, it's a true-positive one that's simply too coarse-grained.
So: useMemo optimizes away spurious re-creations of the value object on unrelated re-renders. It does nothing for the actual problem, which is that
one Context value bundling three independent, differently-changing pieces of state is a single unit of change as far as React is concerned — no amount of memoizing the bundle changes that granularity.
11. Splitting Contexts to fix it for real
Since subscriptions are per-Context-object (Section 4), the real fix is to stop bundling unrelated, independently-changing state into one Context:
const UserContext = createContext(null);
const ThemeContext = createContext(null);
const NotificationContext = createContext(null);
function AppProviders({ children }) {
const [user, setUser] = useState({ name: "Mayank" });
const [theme, setTheme] = useState("dark");
const [notifications, setNotifications] = useState([]);
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<NotificationContext.Provider value={{ notifications, setNotifications }}>
{children}
</NotificationContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}Now UserBadge calls useContext(UserContext) and is recorded, on its Fiber, as depending on UserContext — and only UserContext. When setNotifications fires, NotificationContext's Provider gets a new value,
and React walks the tree looking for NotificationContext dependents. It
finds NotificationBell. It does not find UserBadge or ThemeToggle, because they never subscribed to NotificationContext in the first place — there's no entry for it on their Fibers at all. They simply don't re-render.
Get Mayank Thakur’s stories in your inbox
Join Medium for free to get updates from this writer.
Console output after one click, now:
NotificationBell renderedThat’s the whole fix. No memoization tricks, no extra libraries — just
matching the granularity of your Contexts to the granularity of the state
that actually changes independently. As a rule of thumb: split a Context
along its update boundaries, not along “logical” groupings that feel tidy in
one file.
12. Context vs Redux: an implementation-level comparison
This is the comparison people actually want but rarely get explained at the mechanism level. Both solve “avoid prop drilling for global-ish state,” but their re-render models are fundamentally different shapes.
- Subscription unit — Context: whole Context object. Redux: individual selector function per component.
- Change detection — Context: Object.is on the entire Provider value. Redux: Object.is/shallow-equal on the selector’s return value, not the whole store.
- Who decides what “changed” — Context: React, based on Provider value reference. Redux: you, via what your selector extracts and how you compare it.
- Granularity — Context: as coarse or fine as your Context splitting. Redux: naturally fine-grained, per component, per selector.
- Notification mechanism — Context: push, React walks the Fiber tree from the Provider down. Redux: pull + compare, every useSelector re-runs on every dispatch and decides for itself whether to re-render.
How Context notifies consumers. As covered in Section 5: it’s a push model. The Provider changes, React walks its subtree, and force-updates every Fiber that depends on that Context object. There is no per component filtering step — a consumer either depends on the Context (and gets force-updated) or it doesn’t (and is untouched).
How Redux subscriptions work. The Redux store keeps a plain list of listener functions (store.subscribe(listener)). Every dispatch(action) runs your reducer, computes a new state tree, and then calls every registered listener — this part actually sounds similar to Context's "notify everyone" model. The difference is what each listener does.
How useSelector isolates updates. Each component using useSelector(fn) registers its own listener that, on every dispatch, re-runs fn(newState) and compares the result to fn(oldState) from last time (using reference or shallow equality, similarly to Context — no magic here either). Only if that specific selector's output changed does the component actually re-render. So Redux is a pull model at the subscriber level: everyone gets notified that something changed, but each subscriber independently decides whether their slice changed, and only re-renders if so.
Structural sharing. This is what makes useSelector comparisons
meaningful instead of trivially always-different. Well-written Redux reducers follow an immutable-update discipline: when you update state.notifications, the reducer returns a new top-level state object, but state.user and state.theme — the branches it didn't touch — keep their exact same object references from before. This is called structural sharing: only the path from the root to the changed leaf gets new objects; every untouched branch is reused as-is. A useSelector((s) => s.user) in a component that only cares about the user slice sees the same reference as last time (because state.user literally didn't change), so its shallow-equality check passes, and it correctly skips re-rendering — with no manual Context-splitting required to achieve that.
Why Redux can skip unnecessary renders. Put the last two points together: structural sharing guarantees unrelated slices keep stable references across updates, and useSelector compares specifically against the slice your component asked for, not the whole store. That combination gives you
Context-splitting-like granularity automatically, without having to manually carve your state into a dozen separate Context objects up front —which is precisely the ergonomic gap between the two that Section 9–11 walked through by hand for Context.
To be fair to Context: none of this makes Context “bad” — it was never
designed to be a fine-grained store. For state that changes rarely and is
read by many components (theme, locale, auth user), Context’s coarser model is a complete non-issue in practice. The mismatch only bites when you put frequently-changing, independently-varying state into one shared Context — which is a design choice, not an inherent flaw in the API.
13. Conceptual pseudo-code
The following is intentionally invented, simplified pseudo-code to make the behavior from Sections 3–5 concrete — it is not React’s actual source and skips real concepts like lanes, fiber alternates, and the render/commit split entirely.
// --- Conceptual model, NOT React's real implementation ---
function createContext(defaultValue) {
return {
currentValue: defaultValue,
};
}
function renderProvider(context, newValue, subtreeRoot) {
const previousValue = context.currentValue;
context.currentValue = newValue;
if (!Object.is(previousValue, newValue)) {
propagateContextChange(context, subtreeRoot);
}
// if equal: do nothing further for this context - no walk happens
}
function useContext(context, currentFiber) {
// record the subscription, implicitly, as a side effect of reading
currentFiber.dependencies.add(context);
return context.currentValue;
}
function propagateContextChange(context, subtreeRoot) {
walk(subtreeRoot, (fiber) => {
if (fiber.dependencies.has(context)) {
scheduleRerender(fiber);
}
// real React can additionally skip walking into child subtrees that
// contain no dependents at all and have no other pending work -
// omitted here for clarity
});
}Compare that to a similarly conceptual sketch of useSelector:
// --- Conceptual model of useSelector, NOT Redux's real source ---
function useSelector(selectorFn, currentFiber, store) {
const previousResult = currentFiber.lastSelectorResult;
const newResult = selectorFn(store.getState());
store.subscribe(() => {
const latest = selectorFn(store.getState());
if (!Object.is(latest, currentFiber.lastSelectorResult)) {
currentFiber.lastSelectorResult = latest;
scheduleRerender(currentFiber);
}
// if equal: this component silently skips re-rendering,
// even though the store did change
});
currentFiber.lastSelectorResult = newResult;
return newResult;
}The shape of both is “compare with Object.is, re-render only on change" — the difference is what gets compared: the whole Context value vs. one selector's narrow output.
14. Diagrams
Provider → Consumers
AppContext object — React has no visibility into which field each one actually reads.All three are subscribed to the same Context object, regardless of which
field each one destructures.
Context update flow (push model)
value reference on the Provider makes React force-rerender every subscribed consumer, regardless of which field changed.Redux / useSelector flow (pull + compare model)
useSelector re-runs on each dispatch, but only re-renders if its own selected slice actually changed.Reference comparison, visually
Object.is compares addresses, not contents — a new object reference triggers every consumer to re-render, even ones reading unrelated fields.Structural sharing, visually
useSelector skip re-renders correctly.15. Performance, misconceptions, interview questions, best practices
Performance considerations
- A Context whose value changes on every keystroke or every tick, and is read by dozens of components, is the worst-case shape for this API — you’re paying a full re-render of every consumer on every change.
- The cost isn’t just the consumer’s own render — it’s every non-memoized
child of that consumer too, since a re-render cascades downward normally from that point. - Splitting Contexts (Section 11) is the direct fix; reaching for Redux, Zustand, or Jotai is the fix when splitting stops being practical (e.g. the state is large, deeply interrelated, or needs middleware/devtools).
Common misconceptions
❌ “Context is a lightweight alternative to Redux for global state.” It’s a prop-drilling bypass, not a state manager. It has no concept of actions, middleware, or fine-grained subscriptions. You can build a crude state manager on top of it (
useReducer+ Context), but the re-render model described in this article still applies to whatever you build.❌ “
React.memoprotects a component from Context-triggered re-renders."React.memoonly compares props. A component reading Context viauseContextis force-rerendered by React's context-propagation walk regardless of its memo status — memoization and context subscription are orthogonal mechanisms.❌ “
useMemo-wrapping the Provider's value always fixes re-render issues." As shown in Section 10, it only helps when the re-render was spurious (unrelated to the actual dependencies). It does nothing when the value you care about genuinely changed.❌ “A Context update re-renders the whole app.” Only components that call
useContextfor that specific Context object are force-rerendered (plus their normal render cascade downward). Unrelated branches of the tree that don't consume that Context are untouched.
Interview questions worth being able to answer
- Why does a component using only one field of a Context object re-render when a different field changes? — Because subscriptions are tracked per Context object on the Fiber, not per field; React only compares the whole
valuereference, and has no visibility into which fields your render function actually reads. - Does wrapping a component in
React.memoprevent it from re rendering on a Context change? — No.React.memoonly short-circuits prop-driven re-renders from a parent; it has no effect on the context propagation mechanism. - What does
useMemoactually optimize when memoizing a Context value? — It avoids creating a new object reference on renders where none of the listed dependencies changed. It does not reduce re-render granularity when a dependency genuinely changes. - Why does React use
Object.isinstead of a deep comparison for Context values? — Cost (O(1)vsO(n)) and unambiguity (deep equality has no single correct definition for functions, class instances, etc.); it's also consistent with the reference/shallow-equality model React uses elsewhere (useMemo,useEffectdeps,React.memo). - How would you reduce unnecessary re-renders in a Context-heavy app? — Split Contexts along independent update boundaries (Section 11); keep frequently-changing state out of Contexts that are also read by components that don’t need that specific slice; consider a selector-based library if the state shape doesn’t split cleanly.
- How does
useSelectoravoid re-rendering components whose slice of state didn't change, given that Redux notifies all subscribers on every dispatch? — Every subscriber independently re-runs its own selector and compares the result to the previous result; combined with structural sharing (unrelated state branches keep the same reference across updates), unaffected selectors return the same reference and skip re rendering.
Best practices
- Split by update frequency and ownership, not by what “feels” logically grouped.
UserContextandThemeContextbeing in separate files is fine even if they're conceptually both "app settings" — what matters is that they change independently. - Keep Context values as narrow as you can — prefer several small, precisely-scoped Contexts over one large “app state” Context.
- Reach for
useMemoon Provider values as a hygiene habit, understanding clearly what it does and doesn't buy you (Section 10) — it's cheap insurance against spurious re-creation, not a fix for real dependency changes. - Reach for a selector-based library (Redux +
useSelector, Zustand, Jotai, Recoil) once Context-splitting stops being practical — typically when state is large, deeply interrelated, needs middleware/async handling, or benefits from devtools/time-travel debugging. - Verify, don’t guess — React DevTools’ Profiler tab will show you exactly which components re-rendered and why (including “Context changed”) for any interaction. Use it before and after any Context refactor to confirm it actually helped.
16. Summary and key takeaways
- Context exists to solve prop drilling — passing data through intermediate components that don’t use it — nothing more.
- Every
useContext(SomeContext)call implicitly registers the calling component's Fiber as a dependent ofSomeContextspecifically, not of any particular field within its value. - When a Provider’s
valuechanges, React compares old vs. new withObject.is— reference equality, not deep equality — for cost and unambiguity reasons. - If the reference changed, every dependent Fiber is force-rerendered, regardless of which field it actually reads — because React has no visibility into your render function’s internals, only into the fact that you called
useContexton this object. useMemoon the Provider's value prevents spurious re-creation on unrelated renders, but cannot prevent re-renders when a value you actually depend on changes — that's not a bug, it's the dependency array doing exactly what you told it to.- Splitting Contexts along independent update boundaries is the real fix, because subscriptions are tracked per-Context-object — narrower Contexts mean narrower, more accurate subscriptions.
- Redux’s
useSelector, combined with structural sharing in the state tree, achieves fine-grained re-render avoidance automatically — each component compares only the specific slice it asked for, and untouched branches of state keep stable references across updates, without you having to manually split anything. - Context and Redux aren’t really competing on the same axis: Context is a tree-scoped value-passing mechanism with coarse, manual-granularity subscriptions; Redux is a centralized store with automatic, per-selector fine-grained subscriptions. Pick based on how often your data changes and how many components need to react precisely to that specific change — not on which one is “more modern.”









