Web Development Bug Report Paid

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.

John Nguyen John Nguyen @devbugs · November 04, 2025 · 10 min read · 49 Views
Banner: a single React context provider triggering re-renders across a whole component tree

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

  1. Open the catalog with 480 products in a production build.
  2. In React DevTools, Profiler tab, open settings and enable "Record why each component rendered while profiling".
  3. Start recording, type boots in 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.memo on cards. ProductCard was already wrapped in memo. Memo only skips renders caused by a parent re-rendering with equal props. A component that calls useContext re-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":

  1. 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. useMemo on the value fixes this.
  2. Mixed update frequency. The value really does change on every keystroke because fast state lives next to slow state. useMemo does not help here: the memo dependencies include query, 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

#react #performance #react-context #react-compiler #profiler
Written by
John Nguyen @devbugs

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.