Skip to Content
React in a Week
Day 6 · Modern React 19

Performance and the Compiler

Find out why components re-render, see what memo, useMemo and useCallback do, let the React Compiler memoize for you, and keep Effects small with useEffectEvent.

React is fast by default, and most apps never need any performance work. Still, you'll hear people talk a lot about "unnecessary re-renders", and you'll find older code full of useMemo and useCallback. This lesson explains what that's about, then shows you the modern answer: a compiler that adds those optimizations for you, so you can write plain, readable components.

When does a component re-render?

A component renders again when:

  1. its own state changes;
  2. its parent renders again, which by default renders all of its children too;
  3. a context it reads changes.

Rule 2 surprises people. Type in the input below and watch the console: BookList renders on every keystroke, even though the books didn't change. React shows each message twice in development because of Strict Mode, so look for pairs.

Live code· Re-rendering children

Is this a problem? Usually no. Rendering a small list takes a fraction of a millisecond, and React only touches the real DOM where something actually changed. A re-render is only a problem when it's slow enough for a person to notice.

So the first rule of performance work is: measure before you change anything. Two tools help:

  • The Profiler tab in React Developer Tools records an interaction and shows which components rendered, why, and how long each took.
  • Since React 19.2, the Performance panel in Chrome DevTools has React Performance tracks. They show what React was working on at each priority, for example a blocking update for a click versus a Transition, next to the browser's own timeline.

Memoization: the manual way

If a component really is slow, you can tell React to skip it when its inputs haven't changed. Storing a result and reusing it while the inputs stay the same is called memoization. React has three tools for it:

  • memo(Component) skips re-rendering a component when its props are the same as last time.
  • useMemo(() => compute(), [deps]) reuses a calculated value until a dependency changes.
  • useCallback(fn, [deps]) reuses the same function object until a dependency changes.

They depend on each other in ways that are easy to get wrong. In this sandbox, BookList is wrapped in memo, but it still re-renders on every keystroke. Can you see why before reading on?

Live code· Why memo isn't enough

Every render of App creates a new handleDelete function. To memo, a new function is a changed prop, so it renders anyway. The manual fix is useCallback:

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

const handleDelete = useCallback((id) => {
  setBooks((current) => current.filter((book) => book.id !== id));
}, []);

Try it in the sandbox. Now typing logs nothing. But look at what it cost: an extra import, a wrapper, a dependency array to keep correct, and a bug waiting to happen the next time someone adds an inline function. Doing this all over an app is tiring and easy to get wrong.

The React Compiler

The React Compiler is a build-time tool that adds this memoization for you. It reads your components while your app is being built and rewrites them so that values, functions and JSX are reused when their inputs haven't changed. It even handles cases that are impossible by hand, such as memoizing after an early return.

React Compiler 1.0, the first stable release, came out in October 2025. With it on, the sandbox above would skip BookList without memo or useCallback, and you'd write the plain version.

Installing it

The compiler is a Babel plugin called babel-plugin-react-compiler. React's docs suggest pinning an exact version, because a new compiler version can change what gets memoized:

npm install -D --save-exact babel-plugin-react-compiler@latest

Then add it to your build tool:

With @vitejs/plugin-react version 6 or newer, also install @rolldown/plugin-babel, then use the compiler preset:

npm install -D @rolldown/plugin-babel
vite.config.js
import { defineConfig } from 'vite';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';

export default defineConfig({
  plugins: [
    react(),
    babel({
      presets: [reactCompilerPreset()],
    }),
  ],
});

If you use Babel directly, add 'babel-plugin-react-compiler' as the first entry in the plugins array of your babel.config.js. It must run before other plugins.

Checking that it works

Restart your dev server, open your app and open React DevTools. Components the compiler has optimized show a Memo badge with a small sparkle icon next to their name. If you don't see it, check the terminal for build errors and make sure you restarted the dev server.

Writing code for the compiler

The compiler assumes your components follow the Rules of React you met on Day 2: components and Hooks are pure, props and state are never mutated, and Hooks are called at the top level. When it finds a component that breaks these rules, it skips that component and leaves it as it was. Your app still works, but that component isn't optimized.

To catch those problems early, use the official lint rules:

npm install -D eslint-plugin-react-hooks@latest
eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';

export default defineConfig([
  reactHooks.configs.flat.recommended,
]);

The linter works even without the compiler, and the Vite React template already includes it.

Quick check

You've turned on the React Compiler. A teammate asks if they should wrap a new component's event handlers in useCallback. What's the best answer?

Effect events with useEffectEvent

Performance isn't only about rendering. An Effect that disconnects and reconnects for no reason is slow in its own way. On Day 4 you learned that an Effect must list every reactive value it reads as a dependency. Sometimes, though, you want to read a value without reacting to it.

In this book club, the Effect connects to a room. When the connection is ready, it logs a greeting in the current theme. Change the theme and watch the console.

Live code· An Effect that reconnects too often

Toggling the theme disconnects and reconnects to the club, which is wasteful. The theme only affects the greeting, not the connection. But you can't remove theme from the dependencies, because the Effect reads it, and lying to React about dependencies causes bugs.

useEffectEvent, stable since React 19.2, solves this. It wraps the "event" part of the Effect in a function that always sees the latest props and state, but isn't a dependency:

Live code· useEffectEvent

Now toggling the theme doesn't reconnect, but changing the room does. And when you change the room, the greeting still uses the current theme, because onConnected always reads the latest value.

Try it

Log a visit, not every book change

This page logs a visit with the number of books in your list. Click Add a book a few times: each click logs a new "visit", which is wrong. A visit should only be logged when the page changes, but the log should still include the current number of books. Fix it with useEffectEvent.

Live code· Challenge: logVisit
Show solution
import { useEffect, useEffectEvent, useState } from 'react';

function logVisit(page, bookCount) {
  console.log(`Visit: ${page} (you have ${bookCount} books)`);
}

function Page({ page, books }) {
  const onVisit = useEffectEvent((visitedPage) => {
    logVisit(visitedPage, books.length);
  });

  useEffect(() => {
    onVisit(page);
  }, [page]);

  return <h2>{page}</h2>;
}

export default function App() {
  const [page, setPage] = useState('home');
  const [books, setBooks] = useState(['The Hobbit']);
  return (
    <>
      <button onClick={() => setPage('home')}>Home</button>{' '}
      <button onClick={() => setPage('stats')}>Stats</button>{' '}
      <button onClick={() => setBooks([...books, `Book ${books.length + 1}`])}>
        Add a book
      </button>
      <Page page={page} books={books} />
    </>
  );
}

The Effect now depends only on page, so it runs when you change page. books.length is read inside the Effect Event, so it's always current without being a dependency. Passing page as an argument makes it clear which page the visit was for.

Quick check

Which value belongs inside a useEffectEvent rather than in the Effect's dependency array?

Day 6 progress

0/7
Next: Testing

On this page