Skip to main content

Command Palette

Search for a command to run...

React.memo won't stop a Context re-render — here's what does

One identity rule sits under memo, useMemo, useCallback and every Context bug you've hit

Updated
12 min readView as Markdown
React.memo won't stop a Context re-render — here's what does

Wrap the component in React.memo and it stops re-rendering. That's the folklore. Then someone wraps a component that calls useContext, the renders keep coming, and the tool looks broken.

It isn't broken. It's answering a question nobody asked.

Here's a minimal reproduction — constructed, not from an incident:

// src/components/Sidebar.tsx — React 19.2
const ThemeContext = createContext<'light' | 'dark'>('light');

const Sidebar = memo(function Sidebar() {
  const theme = useContext(ThemeContext);
  console.count('Sidebar render');
  return <aside className={theme}>…</aside>;
});

Sidebar takes no props, so memo's prop comparison passes trivially every time. And it still renders on every theme flip. React's memo reference states it outright: a memoized component still re-renders when its own state changes, or when a context it uses changes.

That sentence is most of this post. The rest is why it's true, and what to reach for instead.

Three reasons React renders a component

The smallest model that's still accurate. React renders a component when:

  1. Its own state or reducer updated. setState was called with a value that isn't Object.is-equal to the current one.

  2. It reads a Context whose value changed. Same Object.is comparison, run on the provider's value.

  3. Its parent rendered. No conditions attached.

Reason 3 is the one people underestimate. Mark Erikson's guide to React rendering behaviour puts it plainly: in a normal render, React does not check whether props changed — it renders the children because the parent rendered. Calling setState in your root <App> asks every component below it to render.

So a render cascade is the default, not the bug.

memo, useMemo and useCallback exist mainly to interrupt reason 3. They work by keeping a reference stable across renders so a shallow comparison somewhere upstream can say "same as before, skip it." Three names, one idea: referential identity. (useMemo has a second job — skipping an expensive calculation — which I'll come back to.) None of them touches reason 1 or reason 2, which is exactly why the Sidebar above kept rendering.

ReportPanel is skipped by memo — reason 3 handled. Chart renders anyway, because reason 2 is a separate edge into the tree that runs straight past the gate.

Prop drilling costs you readability, not renders

The shape everyone recognises:

function App() {
  const [user, setUser] = useState<User | null>(null);
  return <Layout user={user} onSignOut={() => setUser(null)} />;
}

// Layout doesn't use `user`. Neither does Header. Both have to declare it.
const Layout = ({ user, onSignOut }: LayoutProps) => (
  <Header user={user} onSignOut={onSignOut} />
);

const Header = ({ user, onSignOut }: HeaderProps) => (
  <UserMenu user={user} onSignOut={onSignOut} />
);

Prop drilling happens because state gets owned at the nearest common ancestor of everything that reads it, and in a real app that ancestor is usually near the root. The data has to physically travel down.

What it actually costs: every intermediate component's type signature describes data it never touches, adding one prop means editing four files, and Layout can't be moved or reused without dragging user along. Those are maintenance costs, and they're real.

What it does not cost is renders. This is the part that surprises people. App re-rendering on sign-in re-renders Layout, Header and UserMenu because of reason 3 — the props are irrelevant to that decision. Swapping the chain for Context re-renders the same set of components. Teams migrate to Context expecting a performance win and measure nothing, because there was nothing to win.

When not to fix it: two levels and one prop is not prop drilling, it's passing props. Introducing a provider there adds indirection and a file, and buys you nothing.

Context moves the wire, not the render

Context gives a subtree a value without threading it through the middle:

// src/context/auth.tsx
const AuthContext = createContext<AuthState | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const value = useMemo(() => ({ user, setUser }), [user]); // see next section
  return <AuthContext value={value}>{children}</AuthContext>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
  return ctx;
}

Two React 19 details worth pinning. <Context> can be rendered directly as the provider instead of <Context.Provider> — the old form still works and is deprecated. And the new use(Context) API can be called conditionally, unlike useContext, which follows the usual hook rules.

Context fits values that are read widely and written rarely: the signed-in user, theme, locale, feature flags, an app-wide config object. Auth is the canonical case because a token refresh happens a few times an hour and half the tree cares about it.

When not to use it: a value that changes on every keystroke, every scroll frame, or every animation tick. A single context read by 200 components and written on each input change is a broadcast to 200 components, and no amount of memo downstream will stop it — that's reason 2 again. Form state belongs in the form. Hover state belongs in the row.

The dotted line is the only difference: the value skips the middle. The solid render path is identical in both.

The value object is the part that bites

The most common Context bug has nothing to do with Context's design:

- <AuthContext value={{ user, setUser }}>{children}</AuthContext>
+ const value = useMemo(() => ({ user, setUser }), [user]);
+ <AuthContext value={value}>{children}</AuthContext>

The object literal is rebuilt on every render of AuthProvider. Object.is says it's a new value, so every consumer re-renders — even when user is the same object it was before. If that provider sits at the root and re-renders for an unrelated reason, the whole app pays.

setUser is safe to leave out of the dependency array: the setter returned by useState has a stable identity across renders, which the useState reference on react.dev documents explicitly.

Splitting the context is the sturdier fix when reads and writes have different audiences:

const AuthStateContext = createContext<User | null>(null);
const AuthActionsContext = createContext<AuthActions | null>(null);
// Components that only sign out subscribe to actions, which never change,
// so they don't re-render when `user` does.

That pattern costs you two providers and two hooks. Worth it when a lot of components dispatch and few of them read; noise otherwise.

What memo actually compares

memo wraps a component and compares the previous props to the next ones, shallowly, with Object.is per key. Pass an inline object or an inline arrow function and every render produces a new reference, the comparison fails, and you've added comparison cost on top of the render you were trying to avoid:

// ❌ memo can never win here — both props are new objects every render
<ExpensiveRow style={{ padding: 8 }} onSelect={() => select(row.id)} />
// ✅ stable references, so the shallow compare can actually short-circuit
const rowStyle = useMemo(() => ({ padding: 8 }), []);
const onSelect = useCallback((id: string) => select(id), [select]);
<ExpensiveRow style={rowStyle} onSelect={onSelect} />

That's the entire relationship between the three. memo is the gate; useMemo and useCallback are what you use to make the props passing through that gate comparable. Without a memoized child somewhere below, wrapping a callback in useCallback accomplishes nothing except allocating a dependency array — the child was going to render anyway under reason 3.

useCallback(fn, deps) is defined as equivalent to useMemo(() => fn, deps), per react.dev's useCallback reference. One returns the function you passed; the other returns whatever your function returned. Same cache, different payload.

useMemo has a second, unrelated job: skipping an expensive calculation. Different motivation, same mechanism. react.dev's guidance is to measure the calculation with console.time first and offers roughly a millisecond as the threshold where memoizing starts to pay. Sorting 30 items is not that. Sorting 30,000 with a comparator that does date parsing might be.

Tool What it stabilises Skip it when
React.memo The component's render, gated on props Props include fresh objects/functions each render, or the component is cheap
useMemo An object/array reference, or a costly result The value is a primitive, or the calculation is trivial
useCallback A function reference No memoized child or effect dependency consumes it

One caveat the docs state and people forget: memoization is an optimisation, not a guarantee. React may re-render a memoized component anyway.

The escape hatch that beats all three

Before reaching for any of them, change the shape of the tree. Two moves cover most cases.

Move state down. If only the search box and the results list read query, they don't need it at the page level. Push the state into the smallest component that contains both consumers and the cascade shrinks to nothing.

Lift content up as children. This one looks like a trick and isn't:

function Page() {
  const [count, setCount] = useState(0);
  return (
    <Counter count={count} onInc={() => setCount(c => c + 1)}>
      <ExpensiveTree />   {/* created here, not inside Counter */}
    </Counter>
  );
}

When Counter's own state updates, <ExpensiveTree /> isn't re-created — the element object is the same reference Page handed over, so React bails out of re-rendering that subtree. Erikson's guide covers this bailout in detail. No memo, no dependency arrays, nothing to keep in sync during a refactor.

Composition is the cheapest optimisation in React because it removes the render instead of guarding it.

Where the simple model leaks

  • Context has no selector. Any change to the value notifies every consumer; you cannot subscribe to one field. As of React 19.2 there's no useContextSelector in the API — splitting contexts is the workaround, and once you're splitting a fourth context to dodge re-renders, you've hand-rolled a worse store than the ones you'd have installed.

  • React Compiler changes the calculus. React Compiler 1.0 shipped on 7 October 2025 and applies memoization automatically, including the equivalent of memo on components. The memo docs now carry a note saying so. It does not repeal reason 2: a context value change still notifies consumers, which implies the provider-shape decisions in this post keep mattering — though I haven't measured a compiled build to confirm the render counts.

  • StrictMode inflates what you see. In development, React double-invokes renders, so a console.count reads double. Profile a production build before believing a number.

  • Every render is not a DOM write. React still diffs and often changes nothing. A "wasted" render of a component returning three <div>s is measured in microseconds.

When this actually pays off

The framework I'd apply, in order:

  1. Profile first. React DevTools' Profiler tells you which component rendered and why. Skip to step 4 only if it points somewhere.

  2. Fix ownership. Can the state move down, or the subtree move up into children?

  3. Fix the provider. Is the context value a fresh object every render? Should it be two contexts?

  4. Then memoize, at the specific component the profiler named.

Steps 2 and 3 are architectural, and this is the part I'd argue hardest for: most React performance complaints are a state-ownership problem wearing a performance costume. memo sprinkled across a component tree adds comparison cost and dependency arrays that drift out of sync during refactors, and it's invisible to the reviewer who deletes a prop six months later.

The honest limitation: everything above assumes you can restructure the tree. In a codebase where the provider sits above a router and forty routes read it, "move state down" is a multi-sprint refactor, and hand-tuned memoization is the pragmatic patch until then. I'd still write down which of the four steps you skipped and why.

FAQ

Does React.memo stop re-renders caused by Context? No. memo only compares props. The memo reference on react.dev states a memoized component still re-renders when a context it consumes changes. Split the context or restructure the tree instead.

Is prop drilling actually a performance problem? Not by itself. The components in the chain re-render because their parent re-rendered, and they'd do the same under Context. The cost of drilling is readability and refactor friction.

useMemo or useCallback — which do I need?useCallback when the thing you're stabilising is a function; useMemo for everything else. They're the same cache — useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).

Does React Compiler mean I can delete all my memo calls? It memoizes automatically as of 1.0 (October 2025), and the React team's guidance is to keep useMemo/useCallback as escape hatches where you need exact control over effect dependencies. Removing them wholesale without a test suite and a profiler run is a bet, not a cleanup.

Should I use Context instead of a state library? For values that are read widely and written rarely, yes. Once you're splitting contexts to control which consumers re-render, you're rebuilding a store's subscription model by hand, and a library will do it better.


The line worth keeping: prop drilling costs readability, Context costs fan-out, and memo only ever sees props. Match the tool to the cost you're actually paying, and profile before you pay anything.

Has anyone here run React Compiler over a codebase with heavily-nested providers and found the context re-render pattern changed shape? I'd like to know whether the split-context workaround is still earning its keep after 1.0, or whether it's turned into noise.

4 views