# React Rendering Performance: Compiler, memo, Profiling

> React rendering performance comes down to rendering less and rendering cheaper. Measure first with the React DevTools Profiler, then move state closer to where it is used, let React Compiler add memoisation for you, and virtualise long lists. Manual useMemo, useCallback and memo are now a last resort, not the starting point.

- Author: Sami Ullah (https://samiullah.tech/about)
- Published: 2026-11-11
- Updated: 2026-11-13
- Tags: react, performance
- Canonical: https://samiullah.tech/blog/react-rendering-performance

React rendering performance in 2027 is mostly about rendering less work and rendering it less often, and React 19 with React Compiler 1.0 handles much of the second part for you. What the compiler cannot fix is state placed in the wrong component, huge lists rendered in full, and expensive work done during render. This post covers how I measure, what the compiler changes, and the fixes that still need a human. Tested in Nov 2026 with React 19, React Compiler 1.0 and TanStack Virtual 3.

## What actually makes a React app slow?

A React app is slow when it does more rendering work than the screen change requires, or when one render does too much. Rendering is React calling your component functions to produce a new tree, then comparing it with the previous one and updating the DOM where they differ.

In practice, the slowdowns I see fall into four groups:

1. **Too many components render.** A state change at the top of the tree causes hundreds of children to render even though only one of them shows something new.
2. **One render is too expensive.** A component sorts 10,000 rows, parses a date for every cell, or builds a large chart config on every render.
3. **Too much DOM.** A table renders 5,000 rows when the screen fits 20.
4. **Work blocks input.** A slow render runs in response to a keystroke, so typing lags and INP (Interaction to Next Paint) goes above 200 ms.

Each group has a different fix, which is why measuring comes first.

## How do I measure rendering performance?

Measure with the React DevTools Profiler for "which components rendered and why", and with Chrome's Performance panel for "where did the time go". Guessing is the most common mistake: wrapping a component in `memo` does nothing if the real cost is layout, not React.

Use these steps:

1. Build a production bundle and run it locally. Development mode adds checks that make React noticeably slower, so timings there are misleading.
2. Open React DevTools, go to Profiler settings, and turn on "Record why each component rendered while profiling".
3. Click record, perform the slow interaction once (type in the search box, open the drawer), and stop.
4. Read the flame chart. Grey bars did not render. Coloured bars did, with warmer colours costing more. Hover a bar to see why it rendered: props changed, state changed, or parent rendered.
5. If React time is small but the interaction is still slow, record the same interaction in Chrome's Performance panel with CPU throttling at 4x and look for long tasks, layout and style recalculation.

The [React docs on the Profiler component](https://react.dev/reference/react/Profiler) also show how to log render timings in code, which helps when you want numbers from a real device.

## What does React Compiler change?

React Compiler is a build-time tool that automatically memoises components, hooks, values and callbacks, so you no longer write most `useMemo`, `useCallback` and `memo` calls by hand. It analyses each component, works out which values depend on which props and state, and caches the results so they are only recomputed when their inputs change.

It reached a stable 1.0 release in October 2025. Next.js 15 supports it through a config flag, and it ships as a Babel plugin for other setups:

```bash
npm install --save-dev --save-exact babel-plugin-react-compiler@latest
```

```ts
// next.config.ts

const nextConfig: NextConfig = {
  experimental: {
    reactCompiler: true,
  },
};

export default nextConfig;
```

The compiler relies on your code following the [Rules of React](https://react.dev/reference/rules): components and hooks are pure during render, props and state are not mutated, and hooks are called unconditionally. Code that breaks these rules is skipped rather than miscompiled, and the `eslint-plugin-react-hooks` rules point out where. Running that lint step before enabling the compiler is the single most useful preparation.

What the compiler does not do:

- It does not move state for you. If a parent holds state that changes on every keystroke, the parent still renders on every keystroke.
- It does not make an expensive calculation cheap. It only avoids repeating it when inputs are unchanged.
- It does not reduce DOM size. Ten thousand rows are still ten thousand rows.

## Do I still need memo, useMemo and useCallback?

You rarely need them in new code once the compiler is on, but it helps to know what each does, because older code and some libraries still depend on them.

| API | What it caches | When I still write it by hand |
| --- | --- | --- |
| `memo(Component)` | Skips rendering a component when its props are shallow-equal | Compiler is off, or a component is excluded with `"use no memo"` |
| `useMemo(fn, deps)` | The result of a calculation | A value must stay referentially stable for an effect or a library |
| `useCallback(fn, deps)` | A function reference | Same as above, for a callback passed to a non-React library |
| React Compiler | All of the above, automatically | Default for new code in 2027 |

Without the compiler, the classic trap is memoising a child and then passing it a new object or arrow function on every render, which defeats the memo. The compiler removes that trap because it caches those objects and functions too.

## Where should state live for good performance?

State should live in the lowest component that needs it, because a state change renders the component that owns it and everything below. Moving state down is the fix that matters most and the one no tool will do for you.

Here is a pattern I see often: a search input whose value lives in the page component, so every keystroke renders the whole page, including a heavy chart.

```tsx

// Before: typing renders Page, Chart and Results on every keystroke.
export function PageBefore({ rows }: { rows: string[] }) {
  const [query, setQuery] = useState("");
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search" />
      <ExpensiveChart />
      <Results rows={rows} query={query} />
    </>
  );
}

// After: only SearchPanel renders while typing; the chart is untouched.
export function PageAfter({ rows }: { rows: string[] }) {
  return (
    <>
      <SearchPanel rows={rows} />
      <ExpensiveChart />
    </>
  );
}

function SearchPanel({ rows }: { rows: string[] }) {
  const [query, setQuery] = useState("");
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search" />
      <Results rows={rows} query={query} />
    </>
  );
}

function Results({ rows, query }: { rows: string[]; query: string }) {
  const q = query.toLowerCase();
  return (
    <ul>
      {rows
        .filter((r) => r.toLowerCase().includes(q))
        .map((r) => (
          <li key={r}>{r}</li>
        ))}
    </ul>
  );
}

function ExpensiveChart() {
  return <div>{/* imagine a large SVG chart here */}</div>;
}
```

The second version needs no memoisation at all. The same idea applies to stores: a component that subscribes to a whole Zustand store or a large context value renders on every change. Select only the field you use, as described in [React state management in 2026](/blog/react-state-management-2026).

## How do I keep typing responsive during heavy renders?

Mark the expensive update as non-urgent with `useDeferredValue` or `useTransition`, so React renders the input first and the heavy result when it has time. This does not make the work faster, but it stops it from blocking the keystroke, which is what INP measures.

```tsx

export function FilterableTable({ rows }: { rows: string[] }) {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Filter rows" />
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        <SlowRows rows={rows} query={deferredQuery} />
      </div>
    </>
  );
}

function SlowRows({ rows, query }: { rows: string[]; query: string }) {
  const q = query.toLowerCase();
  return (
    <ul>
      {rows.filter((r) => r.toLowerCase().includes(q)).map((r) => (
        <li key={r}>{r}</li>
      ))}
    </ul>
  );
}
```

For this to help, `SlowRows` must skip rendering when `deferredQuery` has not changed. With React Compiler that happens automatically; without it, wrap `SlowRows` in `memo`.

## When should I virtualise a list?

Virtualise when a list has hundreds or thousands of rows and scrolling or filtering is slow on a mid-range phone. Virtualisation (also called windowing) means rendering only the rows currently in the viewport plus a small overscan buffer, and positioning them inside a container sized for the full list.

TanStack Virtual is the library I reach for because it is headless: it gives you positions and you render your own markup.

```tsx
"use client";

export function VirtualList({ items }: { items: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
    overscan: 8,
  });

  return (
    <div ref={parentRef} style={{ height: 480, overflow: "auto" }}>
      <ul style={{ height: virtualizer.getTotalSize(), position: "relative", margin: 0, padding: 0 }}>
        {virtualizer.getVirtualItems().map((row) => (
          <li
            key={row.key}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              height: row.size,
              transform: `translateY(${row.start}px)`,
            }}
          >
            {items[row.index]}
          </li>
        ))}
      </ul>
    </div>
  );
}
```

Virtualisation has costs. Browser find-in-page cannot see rows that are not rendered, screen readers announce a shorter list, and variable-height rows need measuring. For an admin table with 50 rows per page, pagination is simpler. The [SaaS analytics dashboard concept project](/work/saas-analytics-dashboard) is the kind of screen where a large event table justifies it, while the [kanban board concept](/work/kanban-board) usually does not, because each column holds a few dozen cards at most.

A board like that has a different hot path: during a drag, only the card being moved and the columns it crosses should render. You can try that interaction, by mouse, touch or keyboard, in my [kanban board demo](/lab/kanban-board), and record it in the Profiler to see which components light up.

## What other quick wins are there?

A few smaller fixes come up often enough to check every time:

- **Stable keys.** Using the array index as `key` in a list that reorders makes React remount rows and lose their state. Use an ID from the data.
- **Expensive work at module level.** A `new Intl.NumberFormat()` created inside a component runs every render. Create it once outside the component.
- **Code splitting.** Load heavy components like chart libraries and editors with `lazy` or `next/dynamic` so they do not block the first render.
- **Server components.** Parts of the page that never change on the client should not be client components. Their rendering cost moves to the server and their JavaScript disappears from the bundle.

## What should you do first?

Record one profile of the slowest interaction in your app on a production build, with 4x CPU throttling, before you change any code. If the Profiler shows a parent rendering many unrelated children, move the state down. If one component dominates, look at what it computes during render. If the list is long, paginate or virtualise. Then enable React Compiler in a branch, run your tests and the hooks lint rules, and compare the same profile. More of my React work is listed on the [React skill page](/skills/react).

## FAQ

### Does React Compiler mean I can delete useMemo and useCallback?

In most components, yes, once the compiler is enabled and your tests pass. The compiler memoises values, callbacks and JSX automatically based on what each piece depends on. Keep manual memoisation only where you need a stable reference for an effect dependency or a third-party library that compares by identity, and remove the rest gradually rather than in one large commit.

### How do I find which component is slow in React?

Open React DevTools, go to the Profiler tab, enable recording why each component rendered, and record the slow interaction. The flame chart shows each commit and how long every component took, coloured by cost. Look for components that render on every keystroke or scroll without their output changing. Chrome's Performance panel then shows whether the time is in React, layout or your own code.

### When should I virtualise a list in React?

Virtualise when a list renders hundreds of rows or more and scrolling or typing feels slow on a mid-range phone. Virtualisation renders only the rows in view plus a small buffer, so the DOM stays small no matter how long the list is. For lists under about one hundred simple rows, pagination or plain rendering is simpler and keeps browser find-in-page working.

### Is re-rendering in React always bad?

No. A re-render is React calling your component function again and comparing the result, which is usually fast. It only becomes a problem when the component is expensive, when it happens on every keystroke or animation frame, or when it leads to large DOM updates. Measure before optimising, because many re-renders cost well under a millisecond and fixing them adds complexity for nothing.
