Erik Pastor
ES
All posts

Software 13 min read

Why did this render? React re-renders, explained

What makes a React component render again when you use useState, useEffect or a context, how to find the renders you don't need, and what changes with React Compiler.

Erik Pastor

Product engineer · Barcelona

In a technical interview I was asked how I’d filter a list of exercises to show them as search results. It sounds like a simple question, and in a way it is, because anyone can run filter over an array, but as soon as you put it inside a React component the real questions start: where do I keep what the user types? Do I also keep the filtered list in state? Do I work it out in a useEffect? What happens to the rest of the screen every time someone presses a key? A lot of performance problems in React share the same root, which is not being clear about what causes a render, so in this post I’m going to use that exercise search as the example to understand it from the start.

What a render is (and why it isn’t bad)

When React “renders” a component, all it does is call your function. Your component returns JSX, React compares it with what it returned last time and, only if something is different, touches the DOM. Rendering doesn’t mean repainting the screen, and most renders are so cheap you’ll never notice them.

It’s easy to get obsessed with a component rendering twice instead of once, but a cheap render costs you nothing. The renders that hurt are the expensive ones nobody needed, and the ones that set off more renders in a chain until the app stutters while you type.

The three reasons a component renders

A component renders again for one of three things: its own state changes, its parent renders, or the value of a context it uses changes.

What tends to surprise people starting out is that props aren’t on the list. Plenty of people think a child renders “because its props changed”, but it renders because its parent rendered, and by default React renders every child of a component whenever that component renders, whether their props changed or not. If the component at the top of your page holds the search text, every key press runs the whole page again.

With that in mind, let’s go to the search. This is the most direct version:

function ExerciseSearch({ exercises }) {
  const [query, setQuery] = useState('');

  const results = exercises.filter((exercise) =>
    exercise.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {results.map((exercise) => (
          <li key={exercise.id}>{exercise.name}</li>
        ))}
      </ul>
    </>
  );
}

Each key press changes query, the component renders, results is worked out again and React updates the list. That’s one render per key, which is what we want, and with a few hundred exercises you won’t notice any of it.

Keeping in state what you can calculate

The most common mistake is thinking the filtered list has to be state too and keeping it in sync with a useEffect:

const [query, setQuery] = useState('');
const [results, setResults] = useState(exercises);

useEffect(() => {
  setResults(
    exercises.filter((exercise) =>
      exercise.name.toLowerCase().includes(query.toLowerCase())
    )
  );
}, [exercises, query]);

It looks tidier, but look at what happens when you type a letter. React renders with the new query and the old results, paints the screen, runs the effect, the effect changes results and React renders again. That’s two renders per key instead of one, and for a moment the list doesn’t match what’s typed in the search box.

The fix is to delete the effect and the second piece of state. If a value can be worked out from the props or state you already have, calculate it during render, like in the first version. A useEffect is for syncing your component with something outside React, like an API request or a DOM widget, not for keeping one piece of state up to date with another. And if what you want is to read a value that lives outside React and changes on its own, like navigator.onLine or an external store, React has a hook built for that, useSyncExternalStore, which subscribes to that source and renders when it changes without you copying the value into state.

And what if the filter really is expensive, because you have thousands of exercises and also search by muscle, equipment and category? Then you wrap the calculation in useMemo, which only repeats it when its dependencies change:

const results = useMemo(
  () => filterExercises(exercises, query),
  [exercises, query]
);

But be careful about putting useMemo everywhere just in case, because it has a cost too and makes the code harder to read. Measure first, then memoize what turns out to be expensive.

Effects that fire in a loop

Now say the search has filters for muscle and equipment, and the results come from an API. A first version could look like this:

const [muscle, setMuscle] = useState('all');
const [equipment, setEquipment] = useState('all');
const [results, setResults] = useState([]);

const filters = { query, muscle, equipment };

useEffect(() => {
  searchExercises(filters).then(setResults);
}, [filters]);

Can you see the problem? filters is a new object on every render, and React compares dependencies with Object.is, so to React { query: 'press' } and another { query: 'press' } created on the next render are different things. The effect runs after every render, changes results, that causes another render, another filters is created, the effect runs again… and you have an infinite loop of requests to your API.

The same happens with functions you define inside the component and then use as an effect dependency. The cleanest fix is to depend on primitive values, which are compared by value, or to create the object inside the effect itself:

useEffect(() => {
  let ignore = false;

  searchExercises({ query, muscle, equipment }).then((data) => {
    if (!ignore) setResults(data);
  });

  return () => {
    ignore = true;
  };
}, [query, muscle, equipment]);

The ignore variable has nothing to do with renders, but while we’re here it prevents another classic search bug: if the response for “pre” arrives after the one for “press”, without it you’d end up showing the results of the old search.

The other effects

useEffect isn’t the only effect hook, and the others change when your code runs relative to the render and to the browser painting the screen.

useLayoutEffect works like useEffect, but it runs after React updates the DOM and before the browser paints. Why would you want that? Imagine the results list is a dropdown under the search box and, if it doesn’t fit on screen, you want it to open upwards. To know that you have to measure its height, and you can only do that once it’s in the DOM:

const listRef = useRef(null);
const [openUp, setOpenUp] = useState(false);

useLayoutEffect(() => {
  const { bottom } = listRef.current.getBoundingClientRect();
  setOpenUp(bottom > window.innerHeight);
}, [results]);

With useEffect, the browser would first paint the list opening downwards, cut off, and a moment later you’d see it jump up. With useLayoutEffect, setOpenUp still causes another render, but React does it before painting, so the user only sees the final result. The price is that all that work blocks painting, so keep it for measuring or positioning things on screen and use useEffect for everything else.

And there’s a newer one that also fits with the previous section: useEffectEvent, stable since React 19.2. Say the search receives an onResults prop to tell its parent every time results arrive. If you call it inside the effect, you have to list it as a dependency, and since the parent probably creates that function on every render, the effect searches again every time the parent renders. useEffectEvent lets you read the latest onResults without it being part of the dependencies:

const notifyResults = useEffectEvent((data) => onResults(data));

useEffect(() => {
  searchExercises({ query, muscle, equipment }).then((data) => notifyResults(data));
}, [query, muscle, equipment]);

Now the effect only repeats when what actually defines the search changes. Just don’t use it to hide dependencies you find annoying: it only makes sense for logic that reacts to something that happened in the effect, like here, announcing that results have arrived.

State that lives too high

Back to the three reasons: when a component renders, every component it creates in its JSX renders too. Imagine query lives in the page that also draws the header, a side panel with the filters and a chart of your workout history. Every key press runs all of that again, even though none of it depends on the text you’re typing.

Before thinking about memoizing anything, the first question is whether that state can move down. If only the search box and its list need query, the state should live in a component that holds just the search box and its list, so each key press renders that part and leaves the rest of the page alone.

React Scan outlines each component as it renders. In the left copy of the page, the search text lives in the page, so each key renders the header, the filters and the chart as well. In the right copy, the search box holds the text and each key renders only the search.

If the search box has to wrap other parts of the page, you can keep them out of it by passing them as children:

function SearchPanel({ children }) {
  const [query, setQuery] = useState('');

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {children}
    </>
  );
}

<SearchPanel>
  <WorkoutHistoryChart />
</SearchPanel>

SearchPanel renders on every key press, but the page created <WorkoutHistoryChart />, and the page hasn’t rendered, so React reuses that element and skips the chart. So when the three reasons say “its parent renders”, the parent is the component that wrote that JSX, wherever it ends up in the tree.

When you can’t move the state down, you have memo. A component wrapped in memo skips its render if its props are the same as last time:

const ExerciseRow = memo(function ExerciseRow({ exercise, onSelect }) {
  return <li onClick={() => onSelect(exercise.id)}>{exercise.name}</li>;
});

If the parent passes onSelect={(id) => setSelected(id)}, that function is new on every render, the props are never equal and memo does nothing. To make it work you have to keep the function stable with useCallback:

const handleSelect = useCallback((id) => setSelected(id), []);

In this case you can skip useCallback: React keeps the set function from useState the same on every render, so onSelect={setSelected} passes a stable function.

That’s why memo, useMemo and useCallback usually travel together, and also why so many people end up filling their code with them without really knowing which ones are doing anything.

The context that renders everything

Context is the third reason on the list, and the hardest one to spot. Every component that reads a context renders when its value changes, and React once again compares that value with Object.is:

function SearchProvider({ children }) {
  const [query, setQuery] = useState('');
  const [favorites, setFavorites] = useState([]);

  return (
    <SearchContext.Provider value={{ query, setQuery, favorites, setFavorites }}>
      {children}
    </SearchContext.Provider>
  );
}

Every time someone types in the search box, the value object is new, and every component that uses SearchContext renders, including the favorites button that has nothing to do with the search text. If you put things that change often in the same context as things that almost never change, you tie the pace of the whole app to whatever changes fastest. Split it into smaller contexts, one for the search and another for favorites, and wrapping value in useMemo when it’s an object.

When typing feels sluggish

There’s one case where, even if you’ve done everything right, the render is still expensive: a very long list that has to redraw on every key press. You have no extra renders left to remove, but the input waits for the list to finish and typing feels heavy.

That’s what useDeferredValue is for. You tell React the list can lag a little behind the input:

const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);

const results = useMemo(
  () => filterExercises(exercises, deferredQuery),
  [exercises, deferredQuery]
);

The input updates straight away with query, and React works out the list with deferredQuery when it has time, dropping that work if another key arrives before it finishes. You save no renders, since React adds one in the background, but React gives the input priority over the list, and in a search box the user is watching the input.

One more thing about filtered lists: use the exercise’s id as the key, never the index. When you filter, the exercise that was in position 3 moves to position 0, and if the key is the index React thinks it’s the same item, reuses the component and mixes up the state of one with another.

React Compiler

Everything I’ve said about memo, useMemo and useCallback is manual work, and easy to get wrong. React Compiler is a tool that runs when you build your app and adds that memoization for you. It’s stable now, works with React 17, 18 and 19, and plugs into Babel, Vite, Metro, Rsbuild and Next.js.

In practice, with the compiler on, the filters object from the loop example is only created again when query, muscle or equipment change, the onSelect function is already stable without useCallback, and children that don’t depend on the search text skip their render without you wrapping anything in memo, so you can delete most of the memo, useMemo and useCallback from the earlier sections.

But it doesn’t fix everything. The compiler memoizes components and hooks, not any function, and it doesn’t share that memoization between different components. It doesn’t change how your state is designed either: if you sync one piece of state with another in a useEffect you’ll still get two renders per key, and if you keep the search text in a huge context, every component reading it will still render when it changes.

How to find the renders you don’t need

None of this helps if you don’t know where the problem is, and guessing about performance rarely goes well, so measure it with these tools.

The React Developer Tools have an option, “Highlight updates when components render”, that draws a border around every component as it renders. Turning it on and typing in the search box is the quickest way to see whether each key press is lighting up half the page.

The Profiler tab in those same tools records what happens while you use the app and tells you how long each component took on each render. If you turn on “Record why each component rendered while profiling” in its settings, it also tells you the reason: which state, prop or hook changed. It’s how you answer the question in this post’s title with data.

If you want something that warns you without recording, React Scan highlights renders right on the page and flags the ones that look unnecessary, and why-did-you-render logs to the console when a component renders with props that are equal in value but different in reference, which is the case of new objects and functions on every render.

And a warning so you don’t go mad: in development, with StrictMode on, React runs your components twice and mounts, unmounts and mounts your effects again on purpose to help you find bugs. If you see everything twice in the console, it isn’t your bug, and it doesn’t happen in production.

Going back to the interview question, filtering a list of exercises is one line of code, but deciding where the typed text lives, what gets calculated and what gets stored, and which parts of the screen need to hear about each key press, is what makes that search feel smooth with ten exercises or with ten thousand. Next time a component renders more than you expected, which of the three reasons do you think it’ll be?

Keep reading

All posts