[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-react-context-re-render-why-memo-doesnt-help":3},{"content":4,"lastModified":5},"\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>So every five seconds, when the count ticked from 3 to 4, the entire table re-rendered. Four hundred rows, each one calling \u003Ccode>can('edit')\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>I did what everyone does first. I wrapped the row component in \u003Ccode>React.memo\u003C\u002Fcode> and moved on.\u003C\u002Fp>\n\n\u003Cp>It changed nothing.\u003C\u002Fp>\n\n\u003Ch2>Memo doesn't guard context\u003C\u002Fh2>\n\n\u003Cp>That took me longer to accept than it should have. \u003Ccode>React.memo\u003C\u002Fcode> 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 \u003Ccode>Object.is\u003C\u002Fcode>, and \"skipping re-renders with memo does not prevent the children receiving fresh context values.\"\u003C\u002Fp>\n\n\u003Cp>Which means the fix is never \"memo harder\". It's either stabilize the value or stop making so many components care about it.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1516116216624-53e697fedbea?auto=format&fit=crop&w=1200&q=80\" alt=\"A dark code editor with several JavaScript files open during a re-render debugging session\" loading=\"lazy\" \u002F>\n\n\u003Ch2>Confirm it before you touch anything\u003C\u002Fh2>\n\n\u003Cp>Open React DevTools, go to the Profiler, click the settings cog, and turn on \u003Cstrong>Record why each component rendered while profiling\u003C\u002Fstrong>. Record a few seconds of your app doing nothing interesting, stop, then click a component in the flame chart.\u003C\u002Fp>\n\n\u003Cp>Mine said \u003Cem>Context changed\u003C\u002Fem> on every row. Not \u003Cem>Props changed\u003C\u002Fem>. If I hadn't looked, I would have kept sprinkling \u003Ccode>useMemo\u003C\u002Fcode> onto props that were never the problem. The other reasons you'll see are \u003Cem>Parent re-rendered\u003C\u002Fem> and \u003Cem>Hook n changed\u003C\u002Fem>, and each one points somewhere different.\u003C\u002Fp>\n\n\u003Ch2>The value object\u003C\u002Fh2>\n\n\u003Cp>Here's what we had (React 19 lets you render the context itself as the provider; earlier versions need \u003Ccode>DashboardContext.Provider\u003C\u002Fcode>).\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-jsx\">function DashboardProvider({ children }) {\n  const [unread, setUnread] = useState(0)\n  const [user, setUser] = useState(null)\n\n  \u002F\u002F new object, new functions, on every render\n  return (\n    &lt;DashboardContext value={{ user, unread, can, markRead }}&gt;\n      {children}\n    &lt;\u002FDashboardContext&gt;\n  )\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Two problems stacked on each other. The object literal gets a new identity on every render of the provider, and \u003Ccode>can\u003C\u002Fcode> and \u003Ccode>markRead\u003C\u002Fcode> get new identities too. React compares the value with \u003Ccode>Object.is\u003C\u002Fcode>, sees a difference, and re-renders every consumer below it.\u003C\u002Fp>\n\n\u003Cp>The documented fix is \u003Ccode>useCallback\u003C\u002Fcode> on the functions and \u003Ccode>useMemo\u003C\u002Fcode> on the object:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-jsx\">const markRead = useCallback((id) =&gt; { \u002F* ... *\u002F }, [])\nconst can = useCallback((action) =&gt; permissions[action] === true, [permissions])\n\nconst value = useMemo(\n  () =&gt; ({ user, unread, can, markRead }),\n  [user, unread, can, markRead]\n)\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>I shipped that and idle churn dropped a lot. Not to zero, because \u003Ccode>unread\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>Split by how often it changes\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-jsx\">\u002F\u002F permissions barely change; the unread count changes every few seconds\n&lt;PermissionsContext value={permissions}&gt;\n  &lt;NotificationsContext value={unread}&gt;\n    {children}\n  &lt;\u002FNotificationsContext&gt;\n&lt;\u002FPermissionsContext&gt;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The table rows read \u003Ccode>PermissionsContext\u003C\u002Fcode>, so they stopped re-rendering entirely. The bell reads \u003Ccode>NotificationsContext\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>useState\u003C\u002Fcode> (and \u003Ccode>dispatch\u003C\u002Fcode> from \u003Ccode>useReducer\u003C\u002Fcode>) 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.\u003C\u002Fp>\n\n\u003Ch2>What I stopped doing\u003C\u002Fh2>\n\n\u003Cp>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 \u003Cem>Context changed\u003C\u002Fem> 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.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ca href=\"\u002Fposts\u002Freact-compiler-bailout-silent-skip\u002F\">how quietly the compiler skips components you expected it to optimize\u003C\u002Fa>, and the same advice applies: verify with the Profiler instead of assuming. A related version of this churn shows up in \u003Ca href=\"\u002Fposts\u002Freact-server-components-dashboard-performance\u002F\">our RSC dashboard work\u003C\u002Fa>.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> so new features start from the right shape instead of inheriting whatever I typed at 11pm.\u003C\u002Fp>\n\n\u003Cp>Context is a subscription, not a prop. Memo guards props. Once those two sentences stick, you stop reaching for \u003Ccode>React.memo\u003C\u002Fcode> when the Profiler says \"Context changed\".\u003C\u002Fp>\n","2026-09-15",1789531342137]