React context re-render storm: every keystroke re-rendered 600 components
Typing in our search box felt sticky, with INP above 400 ms on mid-range laptops. The React Profiler showed the entire product grid re-rendering on every keystroke because one app-wide context held both slow and fast-changing state. Splitting contexts and a selector store cut that to a handful of renders.
After we added instant search to our catalog page, support tickets started describing the search box as "laggy" and "eating letters" on older laptops. Real-user monitoring agreed: Interaction to Next Paint (INP) on the catalog route went from a p75 of 140 ms to 410 ms, and every slow interaction was a keydown or input event on the search field.
The page renders a header, a filter sidebar, and a grid of up to 480 product cards. Typing a single character took 180-260 ms of scripting on a throttled Chrome profile.
Reproducing it
- Open the catalog with 480 products in a production build.
- In React DevTools, Profiler tab, open settings and enable "Record why each component rendered while profiling".
- Start recording, type
bootsin the search box, stop.
Each of the five commits looked the same:
Commit 3 of 5 Render duration: 212.4ms
AppProvider (state changed)
Header (context changed)
FilterSidebar (context changed)
ProductGrid (context changed)
ProductCard x480 (context changed)
CartBadge (context changed)
Over 600 components re-rendered for one keystroke, and the reason given for almost all of them was "Context changed".
What we ruled out
- Missing
React.memoon cards.ProductCardwas already wrapped inmemo. Memo only skips renders caused by a parent re-rendering with equal props. A component that callsuseContextre-renders whenever that context's value changes, memo or not. - The search request itself. Debouncing the network call to 250 ms changed nothing; the cost was synchronous rendering on each input change, before any request.
- Development-mode overhead. The numbers above are from a production build profiled with
react-dom/profiling.
Root cause
A single AppContext held the user session, the theme, the cart, and the search query, and exposed them as one object literal created on every render of AppProvider. The search input called setQuery on each keystroke, which re-rendered AppProvider, which produced a new value object, which told React that every consumer of AppContext had to re-render. Product cards only needed theme and addToCart, but they subscribed to the whole object.
What is happening under the hood
When a context provider renders, React compares the new value with the previous one using Object.is. If they differ, React walks the subtree and schedules a re-render for every component that reads that context, skipping past any memo boundaries in between. There is no built-in way to subscribe to part of a context value: a consumer that reads ctx.theme is notified even if only ctx.query changed.
Two different problems usually hide behind "context causes too many renders":
- Unstable identity. The provider builds
{ ... }inline, so even when nothing meaningful changed (the provider re-rendered because its parent did), consumers get a new object and re-render.useMemoon the value fixes this. - Mixed update frequency. The value really does change on every keystroke because fast state lives next to slow state.
useMemodoes not help here: the memo dependencies includequery, so the object is new on every keystroke anyway.
We had both. Memoizing alone would have fixed parent-driven renders but not the keystroke storm.
What the React Compiler does and does not fix
React Compiler 1.0 was released as stable in October 2025, and it works with React 17, 18 and 19. It automatically memoizes values and JSX inside components, so an inline provider value object is effectively memoized for you, which removes problem 1. It cannot fix problem 2: if the value contains state that changes every keystroke, the memoized object still changes every keystroke, and every consumer is still notified. We enabled the compiler later and confirmed the profile was unchanged until we fixed the data flow.
Environment
| Component | Version / value |
|---|---|
| React / react-dom | 19.2.0 |
| Bundler | Vite 6.3 with @vitejs/plugin-react |
| Browser for profiling | Chrome 141, 4x CPU throttling |
| State | One AppContext with useReducer |
| Catalog size | 480 cards, each with image, price, rating, add-to-cart |
| RUM | web-vitals 5, INP reported per route |
Minimal reproduction
Paste this into a fresh Vite React app (npm create vite@latest repro -- --template react) and replace src/App.jsx. Typing in the box logs one render per card per keystroke.
// src/App.jsx (broken)
import { createContext, memo, useContext, useState } from "react";
const AppContext = createContext(null);
function AppProvider({ children }) {
const [theme, setTheme] = useState("light");
const [cart, setCart] = useState([]);
const [query, setQuery] = useState("");
const value = {
theme, setTheme,
cart, addToCart: (id) => setCart((c) => [...c, id]),
query, setQuery,
};
return <AppContext value={value}>{children}</AppContext>;
}
const useApp = () => useContext(AppContext);
function SearchBox() {
const { query, setQuery } = useApp();
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
const ProductCard = memo(function ProductCard({ id }) {
const { theme, addToCart } = useApp();
console.count("ProductCard render");
return (
<div className={`card ${theme}`}>
Product {id} <button onClick={() => addToCart(id)}>Add</button>
</div>
);
});
const ids = Array.from({ length: 480 }, (_, i) => i);
export default function App() {
return (
<AppProvider>
<SearchBox />
{ids.map((id) => <ProductCard key={id} id={id} />)}
</AppProvider>
);
}
<AppContext value={...}> is the React 19 shorthand for <AppContext.Provider>; the behaviour is identical.
Who is affected
Any React version with the context API, when one context mixes state that changes at very different rates and is read by many components. The cost scales with the number of consumers times their render cost, so it often stays invisible until a list grows or a fast-updating input is added.
This is a paid story
Subscribe to read the full content, access all paid stories, and support the authors.
Add a label
Embed this post
<iframe src="https://www.hibug.me/react-context-rerender-storm" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Related Stories
React 19 useFormStatus pending is always false: the hook must live inside the form
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.