Mert Özen Further With Every Line
Frontend React — #12

Performance in React: Understanding Re-renders, memo, useMemo, useCallback, the React Compiler and the Profiler

Mert Özen Nov 15, 2026 15 min 5 views
Performance in React: Understanding Re-renders, memo, useMemo, useCallback, the React Compiler and the Profiler

Reducing re-renders only helps when there is a measured cost, and often the real cost is the browser's DOM work rather than React. On Bookstore's 5,000-row stock page we measure memo and useCallback cutting row renders from 5,000 to 1, an inline callback undoing that, the React Compiler halving render time while the click stays at 400 ms, and virtualization bringing it down to 40 ms; plus the effect of useDeferredValue and Activity.

Version note: The examples in this post were built and run with React 19.3, React Compiler 1.0 (babel-plugin-react-compiler), @vitejs/plugin-react 6.3, @rolldown/plugin-babel 0.3, TanStack Virtual 3.14, TypeScript 7.0, Vite 8.3, Oxlint 1.93 and Node.js 24; timings were measured in React's profiling build with Playwright 1.65 in headless Chromium, with the CPU slowed down 4 times. Absolute milliseconds vary by machine; what matters is the difference between versions in the same run.

Predict First

We add a stock page to the back office of Bookstore, the online bookstore used as the shared example across this site's series: 5,000 stock lots across three warehouses in one table, a filter by title and author at the top, and a "Pin" button on every row to mark a lot. Since our API copy has 20 books, we use a test data function that builds the lots from these 20 books; the goal is to make render costs measurable.

The user clicks "Pin" on the first row. The only thing that changes is one row's color. How many StockRows render? How many with memo and useCallback added? How many with the React Compiler on? Most importantly: which of these actually makes the click faster for the user? The answer to the last question is not what the first three suggest.

Do Not Optimize Without Measuring: the Profiler and the Profiling Build

In the fourth post we counted renders by putting console.log into components. A count alone says little; the real questions are how long rendering takes and how long the user waits. React's <Profiler> component reports the time spent rendering the tree it wraps on every commit:

import type { ProfilerOnRenderCallback } from 'react';

// <Profiler> calls this in development and in the profiling build; a normal production build never does
export const reportRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration) => {
  console.debug(`[profiler] ${id} ${phase} actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`);
};

The Profiler is off in a normal production build; react.dev points to a separate build for profiling. In Vite we did this with a mode: vite build --mode profiling redirects react-dom/client to react-dom/profiling:

import babel from '@rolldown/plugin-babel'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

// Bookstore.Api address for the dev proxy (README default: http://localhost:5080)
const apiTarget = process.env.BOOKSTORE_API ?? 'http://localhost:5080'

// https://vite.dev/config/
export default defineConfig(({ mode }) => ({
  // React Compiler 1.0: automatic memoization at build time (react.dev, Vite installation)
  plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
  resolve: {
    // '@/...' imports come from "paths" in tsconfig.app.json
    tsconfigPaths: true,
    // `vite build --mode profiling`: production build with <Profiler> timings switched on
    alias: mode === 'profiling' ? [{ find: 'react-dom/client', replacement: 'react-dom/profiling' }] : [],
  },
  server: {
    proxy: {
      // /api/books -> http://localhost:5080/api/books, same origin for the browser
      '/api': { target: apiTarget, changeOrigin: true },
    },
  },
}))

The time the user waits is measured by the browser: the Event Timing API reports, for every interaction, the time from the event to the next paint. It is the measurement that INP in Core Web Vitals is based on. In every run Playwright typed "dune" into the filter and pinned a lot; "React render" is the Profiler's total and "slowest interaction" is the slowest interaction. The Profiler tab of React DevTools and the Performance Tracks that arrived in Chrome DevTools with React 19.2 show the same information visually; since we could not use them in a headless setup, the numbers in this post come from these two APIs.

The starting code has no memoization, like the code in the posts so far:

import type { StockLot } from '@/features/admin/stockLots';

type StockRowProps = {
  lot: StockLot;
  pinned: boolean;
  onTogglePin: (lotId: string) => void;
};

export function StockRow({ lot, pinned, onTogglePin }: StockRowProps) {
  return (
    <tr className={pinned ? 'pinned' : undefined}>
      <td>{lot.lotId}</td>
      <td>{lot.title}</td>
      <td>{lot.author}</td>
      <td>{lot.warehouse}</td>
      <td className="num">{lot.onHand}</td>
      <td>
        <button type="button" aria-pressed={pinned} aria-label={`Pin ${lot.lotId}`} onClick={() => onTogglePin(lot.lotId)}>
          {pinned ? 'Unpin' : 'Pin'}
        </button>
      </td>
    </tr>
  );
}
export function StockPage() {
  const [lots] = useState(() => makeStockLots(5000));
  const [filter, setFilter] = useState('');
  const [pinned, setPinned] = useState<ReadonlySet<string>>(() => new Set());

  const term = filter.trim().toLowerCase();
  const visible = lots.filter((lot) => lot.title.toLowerCase().includes(term) || lot.author.toLowerCase().includes(term));

memo, useCallback and useMemo

memo wraps a component: if the props are the same as in the previous render by Object.is, the component is not called. But if a prop is a new function on every render, the comparison says "different" every time; useCallback keeps the function stable across renders. useMemo keeps the result of a calculation until its dependencies change:

// memo: skip rendering when lot, pinned and onTogglePin are the same as last time (Object.is per prop)
export const StockRow = memo(function StockRow({ lot, pinned, onTogglePin }: StockRowProps) {
  // useMemo: filter the 5,000 lots again only when the filter text changes
  const visible = useMemo(() => {
    const term = filter.trim().toLowerCase();
    return lots.filter((lot) => lot.title.toLowerCase().includes(term) || lot.author.toLowerCase().includes(term));
  }, [lots, filter]);

  // useCallback: the same function on every render, so memo(StockRow) can skip rows
  const togglePin = useCallback((lotId: string) => {
    setPinned((current) => {
      const next = new Set(current);
      if (next.has(lotId)) {
        next.delete(lotId);
      } else {
        next.add(lotId);
      }
      return next;
    });
  }, []);

The third version is identical, except the callback is wrapped in an arrow function; the fourth has no hand-written memoization but the React Compiler is on. We put a counter in every row and counted how many times it was called:

      {/* WRONG: a new arrow function on every render defeats memo(StockRow) */}
      <Profiler id="StockTable" onRender={reportRender}>
        <StockTable lots={visible} pinned={pinned} onTogglePin={(lotId) => togglePin(lotId)} />
no memo                type "dune":   StockRow rendered 4250 time(s)
no memo                pin one lot:   StockRow rendered 5000 time(s)
memo + useCallback     type "dune":   StockRow rendered    0 time(s)
memo + useCallback     pin one lot:   StockRow rendered    1 time(s)
memo, inline callback  type "dune":   StockRow rendered 4250 time(s)
memo, inline callback  pin one lot:   StockRow rendered 5000 time(s)
React Compiler         type "dune":   StockRow rendered 4250 time(s)
React Compiler         pin one lot:   StockRow rendered 5000 time(s)

memo together with useCallback brought row renders from 5,000 down to 1. Wrapping the callback in an arrow function wiped out the whole gain: memo is there, but since every row gets a new onTogglePin each time, no row was skipped. These three tools work together; if one is missing the others do nothing, and the code has become more complex anyway. useMemo's share is small here: filtering a 5,000-element array takes under a millisecond in the profiling build; that is why react.dev's useMemo page suggests answering "is the calculation expensive?" by measuring.

React Compiler 1.0

The React Compiler is a build-time tool that became stable as 1.0 in October 2025. It analyzes your components and hooks and generates code that memoizes values, JSX and functions automatically. With @vitejs/plugin-react 6 the setup is one line in the config above and two packages:

npm install -D babel-plugin-react-compiler@1.0.0 @rolldown/plugin-babel@0.3.1

In the counter output above, the compiled version called all 5,000 rows. This is not a bug, but an important point for understanding what the compiler does: the compiler does not wrap components in memo. Inside each component, if the props have not changed it returns the previous JSX from a cache, so React does not walk into that row. But the map that builds the table runs again because pinned changed, and creates a new element for every row; according to the counter output, the compiler did not memoize the steps of this loop separately. The result is in the timing table:

no memo                type "dune":   React render   36 ms | slowest interaction  240 ms
no memo                pin one lot:   React render   32 ms | slowest interaction  440 ms
memo + useCallback     type "dune":   React render    7 ms | slowest interaction  184 ms
memo + useCallback     pin one lot:   React render    6 ms | slowest interaction  392 ms
React Compiler         type "dune":   React render   17 ms | slowest interaction  192 ms
React Compiler         pin one lot:   React render   15 ms | slowest interaction  400 ms
Compiler + deferred    type "dune":   React render   16 ms | slowest interaction   64 ms
Compiler + deferred    pin one lot:   React render   16 ms | slowest interaction  400 ms
Compiler + virtualized type "dune":   React render    5 ms | slowest interaction   24 ms
Compiler + virtualized pin one lot:   React render    0 ms | slowest interaction   40 ms
final (all of them)    type "dune":   React render    6 ms | slowest interaction   16 ms
final (all of them)    pin one lot:   React render    0 ms | slowest interaction   40 ms

React render time went from 32 ms to 15 ms with the compiler; with hand-written memo, 6 ms. The compiler is not as sharp as the hand-written version, but it saved more than half of the time without changing a single line of component code. The column on the right is the real warning: the click stayed around 400 ms in all of these versions. That is the answer to the last Predict First question: React rendering is not the bottleneck of this page.

What the compiler assumes are the rules we have followed since the start of this series: render must be pure (same output for the same props and state), props and state must not be mutated, and the rules of hooks must not be broken. The compiler silently skips a component that breaks these rules; it does not break it. That is why we cleaned up the Oxlint warning in the tenth post; in this post the virtualizer in the table produced the same warning:

> bookstore-react@0.0.0 lint
> oxlint

src/features/admin/StockTable.tsx:17:23: warning react(incompatible-library): Use of incompatible library help: This API returns functions which cannot be memoized without leading to stale UI. To prevent this, by default React Compiler will skip memoizing this component/hook. However, you may see issues if values from this API are passed to other components/hooks that are memoized

TanStack Virtual's useVirtualizer returns functions that change on every render; the compiler does not memoize that component. The table component is small and the rows are still compiled, so we accepted it and silenced the warning with a comment giving the reason. To keep a component out on purpose there is the compiler's "use no memo" directive; it is meant to be used temporarily to tell whether a bug comes from the compiler, not as a permanent fix. react.dev recommends relying on the compiler for memoization in new code, and leaving existing useMemo and useCallback calls in place or removing them only with careful testing; removing them can change the compiler's output.

The Real Cost: a 5,000-Row DOM and Virtualization

Where do the 400 ms of the click go? Even when React changes only one row's class, the browser recalculates style and layout for a 5,000-row table. When the first letter is typed into the filter, thousands of <tr>s are removed from the DOM. This cost does not show up in the Profiler, because it is the browser's work, not React's. The fix is not fewer renders but less DOM: virtualization puts only the visible rows into the DOM.

npm install @tanstack/react-virtual@3.14.26
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { StockRow } from '@/features/admin/StockRow';
import type { StockLot } from '@/features/admin/stockLots';

type StockTableProps = {
  lots: StockLot[];
  pinned: ReadonlySet<string>;
  onTogglePin: (lotId: string) => void;
};

const rowHeight = 30;

// Virtualized: only the rows inside the scroll box (plus a few extra) are in the DOM
export function StockTable({ lots, pinned, onTogglePin }: StockTableProps) {
  const scrollBox = useRef<HTMLDivElement>(null);
  // Known and accepted: the compiler skips this small component; StockRow below is still compiled
  // oxlint-disable-next-line react/incompatible-library
  const virtualizer = useVirtualizer({
    count: lots.length,
    getScrollElement: () => scrollBox.current,
    estimateSize: () => rowHeight,
    overscan: 10,
  });
  const items = virtualizer.getVirtualItems();
  // Empty rows above and below keep the scrollbar the size of the full list
  const before = items.length > 0 ? items[0].start : 0;
  const after = items.length > 0 ? virtualizer.getTotalSize() - items[items.length - 1].end : 0;

  return (
    <div ref={scrollBox} className="stock-scroll">
      <table className="stock">
        <thead>
          <tr><th>Lot</th><th>Title</th><th>Author</th><th>Warehouse</th><th>On hand</th><th /></tr>
        </thead>
        <tbody>
          {before > 0 && <tr style={{ height: before }} aria-hidden="true" />}
          {items.map((item) => {
            const lot = lots[item.index];
            return <StockRow key={lot.lotId} lot={lot} pinned={pinned.has(lot.lotId)} onTogglePin={onTogglePin} />;
          })}
          {after > 0 && <tr style={{ height: after }} aria-hidden="true" />}
        </tbody>
      </table>
    </div>
  );
}

Twenty rows are visible in the 600-pixel box, and with overscan there are 30 rows in the DOM; the empty rows above and below size the scrollbar for the 5,000-row list. The "Compiler + virtualized" rows of the timing table show the result: the click went from 400 ms to 40 ms, typing from 192 ms to 24 ms. No memoization change brought a gain of this scale. Virtualization has a price too: in-page search (Ctrl+F) only finds rows in the DOM, variable row heights need measuring, and screen readers cannot see the whole table. We covered the accessibility side in the Accessibility (a11y) series, and the browser's rendering cost in detail in the Web Performance series post "INP'yi Optimize Etmek: Input Delay, Processing ve Presentation Delay, Long Task'ları Bölmek ve Angular'da Akıcılık".

A Smooth Filter with useDeferredValue and useTransition

There are cases where you cannot virtualize: a chart, a map or content of unknown size. There another tool comes in: putting updates in order of priority. The letter the user typed must appear at once; updating the list can wait.

function AllLotsTab({ lots, pinned, onTogglePin }: AllLotsTabProps) {
  const [filter, setFilter] = useState('');
  // The input shows `filter` at once; the list follows `deferredFilter`, which may lag behind while React is busy
  const deferredFilter = useDeferredValue(filter);
  const term = deferredFilter.trim().toLowerCase();
  const visible = lots.filter((lot) => lot.title.toLowerCase().includes(term) || lot.author.toLowerCase().includes(term));

  return (
    <>
      <input type="search" aria-label="Filter stock" placeholder="Filter by title or author" value={filter}
        onChange={(e) => setFilter(e.target.value)} />
      <p className="muted" role="status">{visible.length} lot(s){filter !== deferredFilter ? ' · updating…' : ''}</p>
      <Profiler id="StockTable" onRender={reportRender}>
        <StockTable lots={visible} pinned={pinned} onTogglePin={onTogglePin} />
      </Profiler>

useDeferredValue first does a fast render with the old value, the input shows the new letter and the screen is painted; then it starts a background render with the new value. If the user types another letter meanwhile, React drops the unfinished render and moves on to the new one. In the timing table the "Compiler + deferred" version was measured without virtualization, with the full 5,000-row table: the slowest interaction while typing went from 192 ms to 64 ms. The list updates in the same time, but the user does not wait while typing. The pin click did not change, because that update is not deferred.

useTransition is the side of the same mechanism that sets state: startTransition(() => setFilter(value)) marks an update as "not urgent" and gives you isPending. If you set the value yourself, use useTransition; if you get the value from props or elsewhere, use useDeferredValue. As we saw in the ninth post, React Router also runs navigations inside transitions.

Keeping Hidden UI with Activity

The stock page has two tabs: all lots and the pinned ones. If you switch tabs with conditional rendering, the hidden tab unmounts; its filter text, scroll position and state are lost. <Activity>, which arrived in React 19.2, hides the tree instead:

      <div className="tabs" role="tablist">
        <button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')}>All lots</button>
        <button type="button" role="tab" aria-selected={tab === 'pinned'} onClick={() => setTab('pinned')}>Pinned ({pinned.size})</button>
      </div>
      {/* Hidden tabs keep their state and DOM; their effects are cleaned up until they are shown again */}
      <Activity mode={tab === 'all' ? 'visible' : 'hidden'}>
        <AllLotsTab lots={lots} pinned={pinned} onTogglePin={togglePin} />
      </Activity>
      <Activity mode={tab === 'pinned' ? 'visible' : 'hidden'}>
        <StockTable lots={lots.filter((lot) => pinned.has(lot.lotId))} pinned={pinned} onTogglePin={togglePin} />
      </Activity>

The user filtered by "dune", pinned the first lot, scrolled the table down, switched to the "Pinned" tab and came back:

<Activity>   before:     filter="dune" status="250 lot(s)" scrollTop=3000
<Activity>   Pinned tab: LOT-00001 Dune
<Activity>   back:       filter="dune" status="250 lot(s)" scrollTop=3000
conditional  before:     filter="dune" status="250 lot(s)" scrollTop=3000
conditional  Pinned tab: LOT-00001 Dune
conditional  back:       filter="" status="5000 lot(s)" scrollTop=0

With Activity the filter, the result count and the scroll position were kept; with conditional rendering everything was reset. The hidden tree stays in the DOM with display: none, its effects are cleaned up (like the virtualizer's scroll listener) and set up again when it becomes visible. If new props arrive while hidden it still renders, but at a lower priority than the rest of the page. The price is memory: the hidden tab's DOM and state stay alive; it is meant for a few views the user switches between often, not for keeping dozens of hidden screens.

In Production

Measure in conditions close to production. In a development build StrictMode renders twice and React runs extra checks; the timings do not reflect reality. Every number in this post comes from the profiling build. For users' real experience you need field data collected by real user monitoring; the first post of the Web Performance series, "Web Performansını Doğru Ölçmek: Lab vs Field, CrUX, p75 ve Angular'da web-vitals ile RUM", covers it.

Code splitting is a performance tool too. The stock page is a lazy route; in the production build it got its own chunk:

> bookstore-react@0.0.0 build
> tsc -b && vite build

vite v8.3.8 building client environment for production...
transforming...
✓ 297 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                           0.53 kB │ gzip:   0.32 kB
dist/assets/index-DxuAccDu.css            3.19 kB │ gzip:   1.09 kB
dist/assets/checkoutLoader-CHmDVjn0.js    0.24 kB │ gzip:   0.20 kB
dist/assets/react-DvXU6kJ2.js             8.41 kB │ gzip:   3.23 kB
dist/assets/StockPage-0KlSAsKS.js        40.14 kB │ gzip:  12.89 kB
dist/assets/CheckoutPage-Dwr05end.js    132.78 kB │ gzip:  40.46 kB
dist/assets/index-VAip40uS.js           368.33 kB │ gzip: 116.41 kB

✓ built in 443ms

With TanStack Virtual the stock page is 40 KB, and the checkout page, carrying react-hook-form and zod since the tenth post, is 133 KB; neither is in the main bundle. A user who comes for the catalog never downloads this code. The small chunk named react-*.js holds the compiler's runtime helpers; since several chunks use it, it was split into its own file.

Turn on the compiler in an existing project gradually. react.dev has a separate incremental adoption guide for this; the lint rules that find violations of the compiler's rules ship in eslint-plugin-react-hooks's recommended presets and work without the compiler installed.

Common Pitfalls

Memoizing without measuring: wrapping every component in memo and putting every function in useCallback makes code unreadable, and it usually leaks somewhere, as in the inline callback example above. First the Profiler, then memoization if needed; in new code, the compiler first.

Mistaking render time for the time the user waits: the Profiler only measures React's rendering. In our example the real cost was the browser laying out 5,000 rows again; Event Timing or the Performance panel of DevTools shows that.

memo failing because of an object or array prop: { ... } and [...] created during render are new references every time, just like inline functions.

Using useMemo for correctness: React may throw a cache away when it needs to; useMemo is a performance hint, not a guarantee that a value is computed "once". For a value that must be created once, use useState's lazy initializer (as in our stock list) or useRef.

A Small Experiment

★☆☆ Set overscan to 0 and to 50, and compare whether you see empty rows during fast scrolling and how many rows are in the DOM.

★★☆ Write the filter with useTransition instead of useDeferredValue: bind the input to its own state, set the value that feeds the list inside startTransition, and add an "updating…" indicator with isPending. Which one gives the same result with less code?

★★★ In the catalog, all eight book cards rendered on every add to cart. Measure this again with the Profiler with the compiler on: how many BookCards render? To render only the card that changed, do you still need to wrap BookCard in memo, or is one of the props (onAdd, quantityInCart) in the way?

We improved performance by measuring: memoization, the compiler, prioritization and virtualization each lower a different cost, and only measuring tells you which one will help. In the next post we make these behaviors permanent: we test components the way a user would with Vitest and React Testing Library, verify the cart calculation as a pure function, and mock the Bookstore API at the network level with MSW.