React Rendering and Performance

React & Next.js: The Complete Developer HandbookLesson 13 of 54

Part 1: React Foundations · 13 of 16

Goal: know exactly why a component re-renders, measure before you optimise, and fix real slowdowns in the right order: better state placement first, memo second, and virtualisation for very long lists.

Sooner or later someone opens the console in the tool library, sees a component log ten renders for one keystroke and decides the app is slow. Maybe it is; usually it isn’t. React is designed to re-render often, and most renders cost a fraction of a millisecond. This lesson gives you a precise model of when React renders, a habit of measuring before changing anything, and a short list of fixes that work, with the cheapest one first.

Why a component renders

After the first render, a component renders again for one of three reasons:

  1. Its own state changed (a useState setter or a useReducer dispatch with a new value).
  2. Its parent rendered. By default, when a component renders, React renders every component in the JSX it returns, and their children, all the way down.
  3. A context it reads changed (Lesson 11).

Notice what is missing: “its props changed”. Props can only change because the parent rendered, and reason two already covers that. The reverse surprises people: a child re-renders when its parent does even if its props are exactly the same. React doesn’t compare props by default, because comparing has a cost too and most components are cheap to render.

Remember also that a render is not a DOM update (Lesson 1). Rendering calls your function and produces JSX; React then compares it with the previous result and touches the DOM only where something differs. A card that re-renders with the same name and status costs a function call and a comparison, and the page doesn’t change at all. A re-render is only a problem when it is slow enough for a person to notice.

Seeing it: the catalogue on every keystroke

The catalogue page has a “Suggest a tool” box where members can propose new tools for the library. Its text lives in state in CatalogPage, next to the grid. Each card logs when it renders. Run it, note the eight lines from the first render, then type one letter in the box:

import { useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill' }, { id: 't2', name: 'Extension ladder' },
  { id: 't3', name: 'Orbital sander' }, { id: 't4', name: 'Hedge trimmer' },
  { id: 't5', name: 'Pressure washer' }, { id: 't6', name: 'Tile cutter' },
  { id: 't7', name: 'Stud finder' }, { id: 't8', name: 'Lawn aerator' },
];

function ToolCard({ tool }) {
  console.log('render', tool.name);
  return <li>{tool.name}</li>;
}

function CatalogPage() {
  const [suggestion, setSuggestion] = useState('');

  return (
    <>
      <label htmlFor="suggest">Suggest a tool</label>{' '}
      <input id="suggest" value={suggestion} onChange={(e) => setSuggestion(e.target.value)} />
      <ul>
        {TOOLS.map((tool) => <ToolCard key={tool.id} tool={tool} />)}
      </ul>
    </>
  );
}

Every keystroke logs all eight cards again. Typing changes suggestion, so CatalogPage renders, so every ToolCard in its JSX renders, although none of them uses the suggestion and none of their props changed. With eight tiny cards you will never feel it. With a few hundred cards that each format dates and work out availability, on a slow phone, every keystroke could start to lag.

Compare this with the catalogue’s search box from Lesson 7. There, the cards must re-render on each keystroke, because the search decides which cards are shown. That work is necessary. Here it isn’t, and the fix is to change the structure, not to add an optimisation.

Fix one: move state down

The suggestion text is only used by the input. If it lives in a small component of its own, typing re-renders that component and nothing else:

import { useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill' }, { id: 't2', name: 'Extension ladder' },
  { id: 't3', name: 'Orbital sander' }, { id: 't4', name: 'Hedge trimmer' },
  { id: 't5', name: 'Pressure washer' }, { id: 't6', name: 'Tile cutter' },
  { id: 't7', name: 'Stud finder' }, { id: 't8', name: 'Lawn aerator' },
];

function ToolCard({ tool }) {
  console.log('render', tool.name);
  return <li>{tool.name}</li>;
}

function SuggestTool() {
  const [suggestion, setSuggestion] = useState('');
  return (
    <>
      <label htmlFor="suggest-tool">Suggest a tool</label>{' '}
      <input id="suggest-tool" value={suggestion} onChange={(e) => setSuggestion(e.target.value)} />
    </>
  );
}

function CatalogPage() {
  return (
    <>
      <SuggestTool />
      <ul>
        {TOOLS.map((tool) => <ToolCard key={tool.id} tool={tool} />)}
      </ul>
    </>
  );
}

The first render logs the eight cards, and then typing logs nothing at all. CatalogPage has no state, so it never re-renders; only SuggestTool does. This is the same advice as Lesson 11’s “keep search text out of Context”, seen from the other side: keep state as close as possible to the components that use it. It costs nothing, needs no special API and usually makes the code easier to read too.

A related technique helps when a stateful wrapper must sit around expensive content, such as a collapsible panel around the grid. Give the wrapper the content as children:

function CollapsiblePanel({ title, children }) {
  const [open, setOpen] = useState(true);
  return (
    <section>
      <button aria-expanded={open} onClick={() => setOpen(!open)}>{title}</button>
      {open && children}
    </section>
  );
}

// In CatalogPage:
<CollapsiblePanel title="All tools">
  <ToolGrid tools={TOOLS} />
</CollapsiblePanel>

The <ToolGrid> element is created by CatalogPage, not by the panel. When the panel’s own state changes, it re-renders, but children is the same element object it received last time, so React doesn’t render the grid again. You saw the same effect with BasketProvider in Lesson 11.

Fix two: memo on ToolCard

Sometimes the state really does belong in the parent. The basket is the example: the page owns it, and each card shows whether its tool is in it. Adding the drill changes the basket, the page re-renders, and by default all eight cards render again, though only the drill’s card has anything new to show.

memo wraps a component and tells React: “if the parent re-renders and every prop is the same as last time, skip rendering me and reuse the last result.” Props are compared one by one with Object.is, a shallow comparison. Run this and press the first Add button:

import { memo, useCallback, useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill' }, { id: 't3', name: 'Orbital sander' },
  { id: 't6', name: 'Tile cutter' }, { id: 't7', name: 'Stud finder' },
  { id: 't8', name: 'Lawn aerator' },
];

const ToolCard = memo(function ToolCard({ tool, inBasket, onToggle }) {
  console.log('render', tool.name);
  return (
    <li>
      {tool.name}{' '}
      <button onClick={() => onToggle(tool.id)}>{inBasket ? 'Remove' : 'Add'}</button>
    </li>
  );
});

function CatalogPage() {
  const [basket, setBasket] = useState([]);

  const toggle = useCallback((toolId) => {
    setBasket((current) =>
      current.includes(toolId) ? current.filter((id) => id !== toolId) : [...current, toolId]
    );
  }, []);

  return (
    <>
      <ul>
        {TOOLS.map((tool) => (
          <ToolCard key={tool.id} tool={tool} inBasket={basket.includes(tool.id)} onToggle={toggle} />
        ))}
      </ul>
      <p>Tools in basket: {basket.length}</p>
    </>
  );
}

The first render logs five cards. After the click, “Tools in basket” shows 1 and only “render Cordless drill” is logged. For the other four cards, tool is the same object from TOOLS, inBasket is still false and onToggle is the same function, so memo skips them.

That last prop is why useCallback is there (Lesson 10). Without it, toggle would be a brand-new function on every render of the page, Object.is would call it a different prop, and every card would render anyway. Try it: replace onToggle={toggle} with onToggle={(id) => toggle(id)}, run it, press Add, and all five cards log again. The same goes for any object, array or JSX created inline in the parent’s render, such as style={{ … }} or tool={{ ...tool }}. Notice also the updater function in setBasket: because it reads current instead of basket, the callback needs no dependencies and stays the same for ever.

Keep three facts about memo in mind:

  • It only skips renders caused by the parent. A memoised component still re-renders when its own state changes or a context it reads changes.
  • It is a performance hint, not a promise. Never rely on a component not rendering for your app to behave correctly; your components must stay pure (Lesson 1).
  • It has a cost: React stores the previous props and compares them on every parent render, and readers must now keep every prop stable. Use it on components that are measurably slow or render many times, not on everything.

You may also hear about the React Compiler, an optional build-time tool from the React team that adds this kind of memoisation to your components automatically, so much of the hand-written memo, useMemo and useCallback becomes unnecessary. It has to be added to a project’s build setup, and the live editor in this course doesn’t use it. Learn the model in this lesson either way: the compiler works on the same rules (pure components, stable values), and you will read plenty of hand-memoised code in existing projects.

Measure first: the React Profiler

Console logs show that something rendered. To know whether it matters you need how long it took. The React Developer Tools browser extension (for Chrome, Firefox and Edge) adds two tabs to the browser’s developer tools: Components and Profiler. A useful routine:

  1. In the Components tab settings, turn on Highlight updates when components render. Use the page and watch which parts flash. It is a quick way to spot a whole grid re-rendering when you typed in one box.
  2. In the Profiler tab settings, turn on Record why each component rendered while profiling.
  3. Press record, do the one interaction that feels slow (type a word, add a tool), and stop.
  4. Read the flame chart for each commit: wide, warm-coloured bars are the components that took the longest. Select one to see why it rendered, for example “The parent component rendered” or “Props changed: onToggle”.
  5. Make one change, record the same interaction again and compare. Keep the change only if the numbers improved.

Development builds are slower than production builds because React runs extra checks, so treat development timings as relative (“this commit is twice as slow as that one”), and confirm absolute numbers in a production build. Your laptop is also much faster than many members’ phones: Chrome’s Performance panel can throttle the CPU to simulate a slower device, and in development React adds its own tracks to that panel showing renders and commits on the same timeline as everything else.

React also has a <Profiler> component for measuring in code. Its onRender callback receives, among other things, how long the commit’s rendering took. Here each card does about 2 milliseconds of deliberate busy work to stand in for an expensive component. Press Re-render page:

import { Profiler, useState } from 'react';

const NAMES = ['Cordless drill', 'Extension ladder', 'Orbital sander', 'Hedge trimmer',
  'Pressure washer', 'Tile cutter', 'Stud finder', 'Lawn aerator'];

function SlowToolCard({ name }) {
  const start = performance.now();
  while (performance.now() - start < 2) {
    // Pretend this card does expensive work.
  }
  return <li>{name}</li>;
}

function logRender(id, phase, actualDuration) {
  console.log(id + ' ' + phase + ': ' + actualDuration.toFixed(1) + ' ms');
}

function App() {
  const [renders, setRenders] = useState(1);
  return (
    <>
      <button onClick={() => setRenders(renders + 1)}>Re-render page</button>
      <p>Page renders: {renders}</p>
      <Profiler id="ToolGrid" onRender={logRender}>
        <ul>{NAMES.map((name) => <SlowToolCard key={name} name={name} />)}</ul>
      </Profiler>
    </>
  );
}

The console shows one line for the first render (“mount”) and one per click (“update”), each a little over 16 ms: eight cards at 2 ms each, plus a small overhead. Your exact numbers will differ. That is the kind of evidence to act on. For reference, Google’s Interaction to Next Paint metric (Lesson 47) counts a page as responsive when interactions update the screen within 200 ms, and every millisecond spent rendering counts towards that. As an experiment, wrap SlowToolCard in memo and compare the update times.

Very long lists

Eight tools are nothing. A library with thousands of items is different: even if each card is cheap, rendering and keeping thousands of DOM nodes makes the first load slow and scrolling heavy, and memo can’t help with the first render. The options, from simplest:

  • Show less. Pagination or a “Show more” button. Often the best answer for people as well as for performance, and in Next.js the server can send one page at a time.
  • Virtualisation (also called windowing). Only the rows currently visible in the scroll area, plus a few extra, are rendered; as the user scrolls, rows are swapped in and out. Libraries such as TanStack Virtual and react-window do the measuring for you. It adds complexity (variable row heights, keeping scroll position, making browser find-in-page and screen readers cope with rows that aren’t in the DOM), so reach for it when a list is genuinely long, not by default.
  • Stable keys. Whatever you choose, keep keys from data ids (Lesson 7). Index keys make React re-create or mismatch rows when the list is filtered or reordered, which costs time and can mix up input state between rows.

When rendering a large filtered list is slow and you can’t make it faster, React can at least keep typing responsive by treating the list update as lower priority. That is useTransition and useDeferredValue, covered in Lesson 15.

Common mistakes

  • Treating every re-render as a bug. A console full of render logs is not a performance problem until a measurement shows a slow interaction. In review, ask “what did the Profiler show?” before accepting an optimisation.
  • State placed too high. Input text, open/closed flags or hover state kept in a page component that also renders a large grid. Look for state used by only one small part of a big component, and move it into that part.
  • memo defeated by new props. A memoised card receives onReserve={() => …}, style={{ … }} or a freshly built object each render, so it re-renders every time and the comparison is pure overhead. The Profiler’s “why did this render” names the prop.
  • Memoising everything. memo, useMemo and useCallback on every component and value. It makes code harder to read and change, and each one has a small cost. Add them where a measurement points, and remove ones that don’t help.
  • Measuring only in development on a fast laptop. Development builds exaggerate costs, fast machines hide them. Throttle the CPU, and check a production build before drawing conclusions.

Build it: the volunteer dashboard clock

Volunteers have a dashboard with a live clock at the top and a table of tools due back today. The clock’s time is kept in the dashboard component and updated every second, so the whole table re-renders every second: watch the console fill up with row renders. Change the structure so that the rows render once and only the clock updates every second. Don’t use memo; move the state instead.

import { useEffect, useState } from 'react';

const DUE_TODAY = [
  { id: 't1', name: 'Cordless drill', member: 'Amina' },
  { id: 't2', name: 'Extension ladder', member: 'Tomasz' },
  { id: 't5', name: 'Pressure washer', member: 'Grace' },
];

function DueRow({ loan }) {
  console.log('render row', loan.name);
  return <tr><th scope="row">{loan.name}</th><td>{loan.member}</td></tr>;
}

function Dashboard() {
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <>
      <p>Time now: {now.toLocaleTimeString()}</p>
      <table>
        <caption>Due back today</caption>
        <tbody>{DUE_TODAY.map((loan) => <DueRow key={loan.id} loan={loan} />)}</tbody>
      </table>
    </>
  );
}

Hint: which component actually uses now? Give it a component of its own, and take the state and the effect with it.

Show a sample solution
import { useEffect, useState } from 'react';

const DUE_TODAY = [
  { id: 't1', name: 'Cordless drill', member: 'Amina' },
  { id: 't2', name: 'Extension ladder', member: 'Tomasz' },
  { id: 't5', name: 'Pressure washer', member: 'Grace' },
];

function DueRow({ loan }) {
  console.log('render row', loan.name);
  return <tr><th scope="row">{loan.name}</th><td>{loan.member}</td></tr>;
}

function Clock() {
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  return <p>Time now: {now.toLocaleTimeString()}</p>;
}

function Dashboard() {
  return (
    <>
      <Clock />
      <table>
        <caption>Due back today</caption>
        <tbody>{DUE_TODAY.map((loan) => <DueRow key={loan.id} loan={loan} />)}</tbody>
      </table>
    </>
  );
}

The console now shows the three rows once, while the time keeps ticking. Dashboard has no state left, so nothing makes it re-render; only Clock updates each second. The change also made both components simpler: the dashboard describes the layout, and everything about time lives in Clock, where you could later reuse it on other pages.

Check your understanding

Answer each question in a sentence or two before opening the answer.

1. A teammate wrapped ToolCard in memo, but the Profiler still shows every card rendering when the basket changes. How do you find the cause?

With “Record why each component rendered” turned on, select a card in the Profiler: it lists which props changed. Usually it is a function or object created during the parent’s render, such as onReserve={() => reserve(tool.id)} or a new style object. Make it stable (useCallback with an updater function, or a constant outside the component) or pass simpler props such as ids and booleans.

2. The catalogue’s search box re-renders all visible cards on every keystroke. Is that a problem to fix?

Not by itself. The search decides which cards appear, so the grid has to update. It becomes a problem only if a measurement shows typing is slow, for example on a throttled CPU with a large catalogue. Then the options are making each card cheaper, showing fewer cards, or keeping typing responsive with useDeferredValue or a transition (Lesson 15).

3. Name two ways to stop a big grid re-rendering when the user types in an unrelated input on the same page, without using memo.

Move the input’s state down into a small component that contains only the input, so the page doesn’t re-render when it changes. Or, if a stateful component has to wrap the grid, pass the grid in as children: the element is created by the parent, so the wrapper’s state changes don’t re-render it.

Key takeaways

  • A component re-renders when its state changes, its parent renders, or a context it reads changes. A re-render is not a DOM update and is usually cheap.
  • Measure with the React DevTools Profiler (and a throttled CPU) before optimising, and again after.
  • The first fix is structural: keep state close to where it is used, and pass expensive content as children.
  • memo skips renders when every prop is shallowly equal; it only helps if functions and objects passed to it stay stable.
  • For very long lists, show less (pagination) or virtualise, and always use stable keys.

Priority: should know. You should be able to explain why a component rendered, find a slow interaction with the Profiler and pick the simplest fix that works. See memo and Profiler in the React documentation.

Code examples in this course are free to use under the MIT licence. Lesson text © 2026 Farooq Bin Munir.