Skip to main content
ANVISoftware Solutions
Lesson 16 of 18Advanced18 min

Performance

By the end of this lesson

Measure and reduce unnecessary rendering and bundle weight.

Performance in a React application is two separate problems that get discussed as one. The first is how much work happens when something on screen changes. The second is how many bytes the browser downloads before the page responds to anything.

The symptoms differ. Too much rendering work feels like a filter lagging a keystroke behind the keyboard, or a list that stutters as it scrolls. Too much JavaScript feels like a page that looks finished but ignores the first click or two, and it is worst on a mid-range phone on a mobile connection.

The fixes differ as well, so the first job is finding out which problem you have. Guessing is expensive. A large share of the memoisation in React codebases was added to components that were never slow, and it is still there, still being maintained.

Measure before changing anything. The React Developer Tools browser extension adds a Profiler tab, which answers the question worth starting with: what rendered, and how long did it take?

  1. Record the interaction that feels slow

    Open the Profiler tab, start recording, type four characters into the directory search box, then stop. You now have a timeline of every render React performed during that interaction rather than an impression of it.

  2. Go to the widest bar, not the first

    Each bar is one commit — one batch of DOM updates. Width is duration. The first commit is often the slowest for uninteresting reasons, such as a component mounting for the first time, so start with the widest bar that repeats.

  3. Turn on why each component rendered

    In the Profiler settings, enable the option that records the reason for each render. Each entry then tells you whether it re-rendered because its own state changed, because its props changed, or only because its parent rendered. That third case is the one worth removing.

  4. Read the number, not the count

    Four hundred components rendering in five milliseconds is not your problem, however alarming four hundred sounds. One chart component taking sixty milliseconds on every keystroke is, because sixty milliseconds per character is visible.

  5. Profile a production build for absolute numbers

    The development build carries extra checks and deliberately double-invokes some functions, so it is slower than what users get. Use the Profiler in development to find what is rendering and why, then confirm the timings against a production build.

  6. Re-record after each change

    Change one thing, record the same interaction, compare. If the number did not move, revert. An optimisation you cannot measure is a maintenance cost with nothing on the other side of it.

EmployeeDirectory — the three memoisation tools, applied to a list that needed them
TSX
import { memo, useCallback, useMemo, useState } from "react";

type Employee = {
  id: string;
  name: string;
  department: string;
};

// Rendered once per employee. memo means this only re-renders when the
// props it receives actually differ from last time.
const EmployeeRow = memo(function EmployeeRow({
  employee,
  onSelect,
}: {
  employee: Employee;
  onSelect: (id: string) => void;
}) {
  return (
    <li>
      <button type="button" onClick={() => onSelect(employee.id)}>
        {employee.name} ({employee.department})
      </button>
    </li>
  );
});

export function EmployeeDirectory({ employees }: { employees: Employee[] }) {
  const [search, setSearch] = useState("");
  const [selectedId, setSelectedId] = useState<string | null>(null);

  // Recomputed when the list or the search term changes. Other state
  // updates in this component hand back the previous array.
  const visible = useMemo(() => {
    const term = search.trim().toLowerCase();
    if (term === "") return employees;
    return employees.filter((employee) =>
      employee.name.toLowerCase().includes(term)
    );
  }, [employees, search]);

  // A new arrow function on every render would defeat the memo above.
  const handleSelect = useCallback((id: string) => setSelectedId(id), []);

  return (
    <>
      <label htmlFor="employee-search">Search employees</label>
      <input
        id="employee-search"
        type="search"
        value={search}
        onChange={(event) => setSearch(event.target.value)}
      />
      <ul>
        {visible.map((employee) => (
          <EmployeeRow
            key={employee.id}
            employee={employee}
            onSelect={handleSelect}
          />
        ))}
      </ul>
      {selectedId ? <EmployeeDetail employeeId={selectedId} /> : null}
    </>
  );
}
  • memo wraps a component and tells React to skip the re-render when the new props match the old ones. The comparison is per prop, with ===, so it is reference equality for objects and functions.
  • useMemo caches a computed value between renders. The dependency array is the contract: recompute when employees or search changes, otherwise return the previous array. Returning the same array reference is half the point, because anything downstream comparing by reference then sees no change.
  • useCallback does the same for a function. This is the part that is easy to get wrong: an inline arrow function is a new value on every render, so a memo-wrapped child receiving one always sees changed props. memo on the row without useCallback on the handler does close to nothing.
  • The empty dependency array on handleSelect is safe because setSelectedId is a state setter, and React guarantees setters keep the same identity for the life of the component. If the handler read a value from props, that value would belong in the array.
  • The React ESLint rules check these arrays. Take the warnings seriously — a missing dependency means a stale value, and a stale value is much harder to notice than a slow render.
  • Delete all three and this component behaves identically, with more work per keystroke. That is the test for whether a change is an optimisation: it changes timings, not outcomes, so it needs a measurement to justify it.

Long lists are where rendering cost usually shows up first. Options, in the order worth trying them:

Render fewer rows
Paging, or searching on the server, means the browser never receives two thousand employees. This is the least clever option and often the best one, because no client-side technique beats not having the data.
Virtualise the list
A windowing library renders only the rows inside the viewport plus a small buffer, and swaps them as the user scrolls. Two thousand employees become about twenty elements in the DOM. Reach for it when the full list genuinely has to be scrollable in one go.
What virtualisation costs
Rows need a known or measurable height. The browser's own find-in-page cannot see rows that are not rendered. Linking to a row deep in the list stops working without extra code. The accessibility details need care too: the container and rows must describe the whole list, not the visible slice, or a screen reader user is told there are twenty employees.
Keep keys stable
A key derived from employee.id lets React reuse row instances across renders. An index key makes one deletion look like a change to every row after it, which is a correctness problem first and a performance problem second.
Move repeated work above the list
Building a date formatter or a lookup map inside the row component runs once per row per render. Doing it once above the list, or on the server, removes the multiplication entirely and needs no memoisation to do it.
Split the state that changes often
If one input's value lives in the same component as the list, every keystroke re-renders the list. Moving the input and its state into a smaller component narrows what React has to reconsider, and it needs no comparison machinery at all.
Bundle weight: loading a chart only when someone asks for it
TSX
"use client";

import dynamic from "next/dynamic";
import { useState } from "react";

// The charting library is no longer part of the initial download. Its
// chunk is fetched the first time this component renders.
const HeadcountChart = dynamic(() => import("./HeadcountChart"), {
  loading: () => <p>Loading chart</p>,
  ssr: false,
});

export function DirectoryInsights() {
  const [showChart, setShowChart] = useState(false);

  return (
    <section>
      <h2 id="headcount-heading">Headcount by department</h2>
      <button
        type="button"
        aria-expanded={showChart}
        aria-controls="headcount-chart"
        onClick={() => setShowChart(true)}
      >
        Show chart
      </button>
      <div id="headcount-chart">
        {showChart ? <HeadcountChart /> : null}
      </div>
    </section>
  );
}
  • next/dynamic puts the imported module in its own chunk. The bytes for the charting library leave the initial download and arrive only if a user opens the chart.
  • The loading fallback is a real state, not a courtesy. On a slow connection the gap is visible, and a button that appears to do nothing gets pressed again.
  • ssr: false keeps the component out of server rendering, which is necessary for code that touches browser APIs at import time. Leave it off when the component can render on the server, because then its content is in the initial HTML.
  • aria-expanded and aria-controls tell a screen reader user that this button reveals something and what it reveals. The container element exists before the chart does, so the relationship is not broken while the chunk loads.
  • Measure the effect rather than assuming it. The production build prints the JavaScript size per route, including the shared first load, and a bundle analyser shows which dependency is responsible. The answer is often one large library doing a job that thirty lines could do.
  • Dynamic import suits anything behind an interaction: a chart, a rich text editor, an export dialog, a map. It is the wrong tool for something every visitor sees straight away, because you have added a round trip and a loading state without reducing what is needed.

Summary

  • Rendering cost and bundle weight are different problems with different symptoms and different fixes
  • Profile first: find what rendered, why it rendered, and how long it took, then change one thing and re-measure
  • memo, useMemo and useCallback work by reference comparison, so an inline function or object prop defeats them
  • Memoisation costs runtime bookkeeping and readability, so it needs a measurement behind it
  • Long lists are usually better served by fetching fewer rows or splitting state than by memoising
  • Dynamic import moves code behind an interaction out of the initial download, at the cost of a round trip and a loading state

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

A colleague has wrapped EmployeeRow in memo to stop the directory re-rendering every row on each keystroke. The parent still passes onSelect={(id) => setSelectedId(id)} inline in the JSX.

The Profiler shows every row still re-rendering. Explain why, fix it, and say what you would record to prove the fix worked.

Show solution

memo compares props with ===. The inline arrow function is a brand new function object on every render of the parent, so the onSelect prop is never equal to the previous one, the comparison fails, and memo re-renders the row exactly as it would have without memo.

The fix is to give the handler a stable identity with useCallback and an empty dependency array, which is safe here because it only calls a state setter. The row's other prop, employee, is already stable: it comes from the employees array and the filter returns the same objects.

To prove it, record the same four keystrokes in the Profiler before and after, with the render reason option on. Before the change, every row appears in each commit with "props changed". After it, only rows that entered or left the filter appear. The commit duration is the number to compare.

There is a second, often better answer: move the search input and its state into their own component, so typing re-renders that component instead of the list. That removes the re-render rather than skipping it, and it needs no memo, no useCallback and no dependency arrays to maintain.

TSX
// Before — memo present, and defeated by the inline handler.
<EmployeeRow
  key={employee.id}
  employee={employee}
  onSelect={(id) => setSelectedId(id)}
/>

// After — one stable function, created once and reused.
const handleSelect = useCallback((id: string) => setSelectedId(id), []);

<EmployeeRow
  key={employee.id}
  employee={employee}
  onSelect={handleSelect}
/>

Think about it

Think about it

A pull request on the employee portal wraps every component in memo, and every handler in useCallback, on the grounds that it can only help.

The application is not currently reported as slow. What would you say in review?

Show solution

It can hurt, in two ways. Each memo adds a props comparison on every render, which for cheap components can cost more than the render it avoids. And the code roughly doubles in size, so every future change has to work out whether a dependency array still lists the right things.

There is also nothing to check the change against. With no measurement before it, there is no measurement after it, so the pull request cannot be evaluated on its own terms. That is the strongest review comment available: not "this is wrong", but "how would we know?"

A better shape for the work: profile the two or three interactions users actually complain about, fix what the Profiler points at, and leave a comment on each memo saying which measurement justified it. Three memo calls with a reason are worth more than sixty without one.

One reasonable counter-argument: on a project already using the React Compiler, most of this memoisation is applied automatically and writing it by hand is redundant rather than harmful. That strengthens the case for removing the manual calls, not for adding them.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A list row is wrapped in memo. Its parent passes onSelect={(id) => select(id)} inline in the JSX. What happens when the parent re-renders?
When is next/dynamic the wrong tool?

Saved in this browser only.