5 min read

React Context Re-renders: Why React.memo Doesn't Help

Our admin dashboard has a notification bell in the top right. It polls an endpoint every five seconds for the unread count, then drops that number into a context that also carries the current user, a permission helper, and a couple of feature flags. Everything reads from that one context, including the 400-row data table in the middle of the page.

So every five seconds, when the count ticked from 3 to 4, the entire table re-rendered. Four hundred rows, each one calling can('edit') to decide whether to show an edit button. Nobody clicked anything. Nothing visible changed. The table just churned in the background of a tab somebody left open all day.

I did what everyone does first. I wrapped the row component in React.memo and moved on.

It changed nothing.

Memo doesn't guard context

That took me longer to accept than it should have. React.memo compares props. Context doesn't travel through props, so a memoized component with a context subscription still re-renders the moment that value changes. The React docs say it plainly: context values are compared with Object.is, and "skipping re-renders with memo does not prevent the children receiving fresh context values."

Which means the fix is never "memo harder". It's either stabilize the value or stop making so many components care about it.

A dark code editor with several JavaScript files open during a re-render debugging session

Confirm it before you touch anything

Open React DevTools, go to the Profiler, click the settings cog, and turn on Record why each component rendered while profiling. Record a few seconds of your app doing nothing interesting, stop, then click a component in the flame chart.

Mine said Context changed on every row. Not Props changed. If I hadn't looked, I would have kept sprinkling useMemo onto props that were never the problem. The other reasons you'll see are Parent re-rendered and Hook n changed, and each one points somewhere different.

The value object

Here's what we had (React 19 lets you render the context itself as the provider; earlier versions need DashboardContext.Provider).

function DashboardProvider({ children }) {
  const [unread, setUnread] = useState(0)
  const [user, setUser] = useState(null)

  // new object, new functions, on every render
  return (
    <DashboardContext value={{ user, unread, can, markRead }}>
      {children}
    </DashboardContext>
  )
}

Two problems stacked on each other. The object literal gets a new identity on every render of the provider, and can and markRead get new identities too. React compares the value with Object.is, sees a difference, and re-renders every consumer below it.

The documented fix is useCallback on the functions and useMemo on the object:

const markRead = useCallback((id) => { /* ... */ }, [])
const can = useCallback((action) => permissions[action] === true, [permissions])

const value = useMemo(
  () => ({ user, unread, can, markRead }),
  [user, unread, can, markRead]
)

I shipped that and idle churn dropped a lot. Not to zero, because unread sits in the dependency array and the poll keeps rewriting it on a timer. The fast thing and the slow things were sharing one subscription.

Split by how often it changes

This is the part that actually fixed it. Separate context by change frequency, so components that only need permissions never subscribe to the polling counter.

// permissions barely change; the unread count changes every few seconds
<PermissionsContext value={permissions}>
  <NotificationsContext value={unread}>
    {children}
  </NotificationsContext>
</PermissionsContext>

The table rows read PermissionsContext, so they stopped re-rendering entirely. The bell reads NotificationsContext and is now the only thing updating on each poll. Same data, same polling, a fraction of the work, and the row component no longer needs memoization at all.

If your consumers mostly write and rarely read, there's a smaller version of the same move: keep the state and the setter in two different contexts. The setter from useState (and dispatch from useReducer) keeps a stable identity for the lifetime of the component, so a button that only calls an action stops re-rendering when the data changes.

What I stopped doing

I over-applied this for about a week and split a theme context into state and setter contexts that had one consumer each. Pointless. A theme that flips once a session can carry an inline object and nobody will ever notice. The Profiler is what tells you which context deserves the surgery: if Context changed shows up during typing, scrolling, or an idle poll, that context is hot. If it shows up when someone opens a settings dialog, leave it alone.

If you run React Compiler, it will usually memoize that inline value object for you, which is nice, because that's the mechanical half of this bug. It won't reorganize who subscribes to what. That part is a decision about where fast state lives. I've written before about how quietly the compiler skips components you expected it to optimize, and the same advice applies: verify with the Profiler instead of assuming. A related version of this churn shows up in our RSC dashboard work.

The habit I keep now is boring but effective: before adding a value to a provider, ask how often it changes. Fast state and effectively frozen data don't belong in the same subscription. Our split provider skeleton lives in Snippet Ark so new features start from the right shape instead of inheriting whatever I typed at 11pm.

Context is a subscription, not a prop. Memo guards props. Once those two sentences stick, you stop reaching for React.memo when the Profiler says "Context changed".