Contents
14 Chapter

Performance optimization (memo · useMemo · useCallback · and the React Compiler)

What memo / useMemo / useCallback do, the common misuses, and what changes and what stays the same once the React Compiler lands.

Chapter 13 covered custom hooks, the tool for reusing logic. This chapter covers the three tools used to make a React app run fastReact.memo, useMemo, useCallback. We also look at how React 19’s React Compiler changes the role of those tools.

These tools are powerful but also frequently misused, so what matters as much as how they work is when to use them and when not to. Chapter 31 (performance · bundles · Web Vitals) covers the procedure of measuring and then deciding which of these tools applies. This chapter sets the basic principles in place; Chapter 31 ties them into measurement-driven decisions.

First — React is fast by default #

This needs to be said up front. React uses a virtual DOM to apply only what actually changed to the DOM, so an average app runs fast enough without any optimization at all.

Before you start optimizing for performance, ask yourself:

  • Is it really slow? (does it feel slow, have you measured it?)
  • Where is it slow? (verify with the React DevTools Profiler)
  • What does it take to make that part fast?

The tools in this chapter are for the case where re-renders happen so often or are so heavy that they actually become a problem — not preventive medicine applied to every component. Keep that in mind as we begin.

Refresher on React’s re-render model #

To understand what these three tools do, you need to remember when re-renders happen.

When state changes, that component and all its children re-render.

Whether the props the child received are the same or different, the child component function is called again. If that work is not expensive, fine — but if the child does heavy computation, or the child’s child does, the cumulative cost adds up.

memo / useMemo / useCallback are all tools for reducing this “unnecessary re-rendering / recomputation”.

React.memo — skip a child’s re-render #

React.memo wraps a component so that it does not re-render when its props are the same as before.

src/Heavy.jsx
import { memo } from 'react';

function Heavy({ value }) {
  console.log('Heavy render');
  // ... heavy work ...
  return <div>{value}</div>;
}

export default memo(Heavy);

Heavy, wrapped in memo, skips re-rendering even if the parent re-renders, as long as the value prop is the same as before — it reuses the previous result.

The trap — object / array / function props become “new” every time #

problematic code
function Parent() {
  const [count, setCount] = useState(0);

  const config = { color: 'red' };  // a new object every render

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <Heavy config={config} />
    </>
  );
}

Every time the parent re-renders, the literal { color: 'red' } is created anew. The contents are the same, but the reference differs each time, so memo compares them as “different props” and the child re-renders every time. As a result, memo becomes useless.

useMemo and useCallback are the tools that fix this.

useMemo — memoize a value #

useMemo remembers the result of a computation and, if the dependencies are the same, returns the previous result instead of recomputing.

src/Parent.jsx
import { useState, useMemo } from 'react';

function Parent() {
  const [count, setCount] = useState(0);

  const config = useMemo(() => ({ color: 'red' }), []);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <Heavy config={config} />
    </>
  );
}

useMemo’s shape looks like useEffect.

const result = useMemo(() => computeFn(), [dependency, array]);
  • First argument — a function that performs the computation and returns the result
  • Second argument — the dependency array (if these values are the same, reuse the previous result)

In the example above, config has an empty dependency array, so it always returns the same object reference. As a result, Heavy (wrapped in memo) does not re-render every time.

useMemo serves two purposes #

You can sort useMemo’s uses into two buckets.

(1) Caching the result of an expensive computation

don't redo expensive work every time
const filtered = useMemo(() => {
  return items.filter(item => item.score > threshold);
}, [items, threshold]);

When items or threshold is unchanged, filter does not run again. When the data is large and filter / sort is heavy, this saves meaningful time.

(2) Stabilizing object / array references (when used with memo)

stabilize an object prop
const config = useMemo(() => ({ color: 'red', size: 'lg' }), []);
return <MemoizedChild config={config} />;

For preserving a reference when passing an object / array to a memo-wrapped child.

useCallback — memoize a function #

useCallback is essentially a function-specific shorthand for useMemo.

useMemo for a function
const handleClick = useMemo(() => () => setCount(c => c + 1), []);
useCallback for the same thing
const handleClick = useCallback(() => setCount(c => c + 1), []);

It is a shorthand that gets rid of the awkward arrow-function-inside-arrow-function. The job is “keep the function reference unchanged as long as the dependencies are unchanged”.

The main use is passing a handler to a memo-wrapped child.

when the child is wrapped in memo and receives a function prop
function Parent() {
  const [count, setCount] = useState(0);

  const handleSave = useCallback(() => {
    console.log('Save');
  }, []);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <MemoizedChild onSave={handleSave} />
    </>
  );
}

Without useCallback, a new function is created every render, and MemoizedChild re-renders pointlessly.

How the three tools relate #

ToolWhat it memoizesWhen to use
memothe component itselfThe parent re-renders often, but the child’s props rarely change, and the child is heavy
useMemothe result of a computation (a value)Expensive computation, or stabilizing object / array references being passed to a memo child
useCallbacka functionPassing a handler to a memo child

useMemo and useCallback are almost meaningless on their own. They acquire meaning when paired with a memo-wrapped child (or when used as an effect’s dependency).

React Compiler — the era of automatic memoization #

The React Compiler, which entered the official track with React 19, is a tool that automatically applies these three tools through compilation. Once it settles in, we will rarely write memo / useMemo / useCallback by hand.

What the Compiler does #

  • Statically analyzes the component function’s code
  • Identifies objects / arrays / functions / computed results that are recreated on every render
  • Automatically inserts code into the compiled output that reuses the previous value when the dependencies haven’t changed

As a result, you can write the Parent example like this and it still works:

with the Compiler enabled
function Parent() {
  const [count, setCount] = useState(0);

  const config = { color: 'red' };  // the compiler stabilizes this automatically

  const handleSave = () => console.log('Save');  // auto-stabilized

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <MemoizedChild config={config} onSave={handleSave} />
    </>
  );
}

MemoizedChild does not re-render pointlessly. The effect is the same as if you had written useMemo / useCallback by hand.

Boundaries where you still do this by hand even with the Compiler #

The following cases still require you to consider memo / useMemo / useCallback directly.

  1. Cases the Compiler’s static analysis cannot reach — dynamic key access, eval-like patterns, or external functions whose dependencies cannot be tracked
  2. Cases where the compiler is intentionally conservative — code regions where side effects are suspected may be skipped from memoization
  3. Cases where an explicit memo(Component) expression matters — components meant to be consumed by other code, like in a library, are safer when the author explicitly states memo
  4. Effect dependencies — when an object / function is passed as an effect’s dependency, even if the Compiler stabilizes it, expressing intent explicitly with useMemo / useCallback makes the code clearer

To summarize, the basic principles of the tools in this chapter remain valid even after the Compiler lands. You just write them by hand much less often.

Note
As of 2026, the React Compiler is steadily stabilizing on the stable track. Both Vite and Next.js can enable it via a compiler plugin. The example code in this book is written assuming no compiler, so memoization is done by hand, but for a real production project it is reasonable to turn the compiler on first and try not to write these by hand if you can avoid it.

Common misunderstandings and traps #

Misunderstanding 1. “If I use these, things get faster, no question” #

Quite the opposite. useMemo / useCallback are not free. They cost the comparison of dependencies and the storage of the previous value. If memoization saves less than that cost, things get slower.

The React docs are clear about the default stance:

The default is not to use them. Add them only when measurement actually shows it’s slow.

Misunderstanding 2. “Just slap memo on and it stops re-rendering” #

As shown above, if the child receives object / array / function props, those need to be stabilized too. memo alone is mostly useless (though if the React Compiler is enabled, the compiler stabilizes them for you, so the effect comes back).

Misunderstanding 3. “If props are the same, it won’t render at all” #

memo does a shallow comparison. It does not look deep into the inside of an object — it compares top-level property references. Every time a new object comes in (when it has not been stabilized), it treats them as different.

Also, if the child component itself has its own state via useState or useContext, it will of course re-render when that state changes, regardless of whether props are the same. memo only blocks re-renders that come down from the parent.

So when should you use them #

These tools are meaningful in situations like:

  1. Each item in a list is a heavy component — if changing one item re-renders every item, the cost is large. Wrap each item with memo, and stabilize the handler passed from the parent with useCallback.
  2. A truly high-cost computation — sorting / filtering / aggregating tens of thousands of items. Cache it with useMemo so the same input does not recompute.
  3. A function / object is used as an effect’s dependency — a fresh reference each time triggers the effect every render. Stabilize with useCallback / useMemo.

For small components, light computation, and static screens, they are rarely needed. They just complicate the code.

Measure first #

The first step of performance optimization is always measurement. The Profiler tab in React DevTools shows visually how often and how long renders take.

  1. Install the React Developer Tools browser extension
  2. Open DevTools → “Profiler” tab
  3. Hit the red record button → perform the slow-feeling action → stop
  4. Look at the bar chart to see which components rendered for how long

Relying on intuition (“this part feels slow”) to start optimizing, without looking at this, tends to leave the real bottleneck untouched while the code gets more complex.

Chapter 31 of this book (performance · bundles · Web Vitals) covers, beyond the Profiler, the procedure for measuring user-perceived performance in actual production using Lighthouse / Core Web Vitals / bundle analysis. The tools in this chapter become candidates for “which tool to apply” after that measurement.

Try it yourself #

Let’s feel memo’s effect with an example that deals with a large list.

src/ListItem.jsx:

src/ListItem.jsx
import { memo } from 'react';

function ListItem({ item, onSelect }) {
  console.log(`render: ${item.name}`);
  return (
    <li onClick={() => onSelect(item.id)} style={{ cursor: 'pointer', padding: '4px' }}>
      {item.name}
    </li>
  );
}

export default memo(ListItem);

src/App.jsx:

src/App.jsx
import { useState, useCallback } from 'react';
import ListItem from './ListItem';

const ITEMS = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Item ${i}` }));

function App() {
  const [selected, setSelected] = useState(null);
  const [count, setCount] = useState(0);

  const handleSelect = useCallback((id) => setSelected(id), []);

  return (
    <div style={{ padding: '16px' }}>
      <p>Selected item: {selected ?? 'none'}</p>
      <p>Unrelated counter: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Counter +1</button>

      <ul style={{ maxHeight: '300px', overflow: 'auto', border: '1px solid #ccc' }}>
        {ITEMS.map(item => (
          <ListItem key={item.id} item={item} onSelect={handleSelect} />
        ))}
      </ul>
    </div>
  );
}

export default App;

Open the console and click the counter button. None of the 1000 ListItems re-renders. You’ll see one log per item at initial mount, then almost no further logs as the counter goes up. When you click an item, you’ll see only that one item (or up to two, including the previously selected one) re-render.

If you want to compare directly:

  • Change export default memo(ListItem); to export default ListItem; in ListItem → clicking the counter logs all 1000 renders every time
  • Remove useCallback and write onSelect={(id) => setSelected(id)} → even with memo, all 1000 re-render (the function reference changes every time)

How the three tools cooperate becomes visible at a glance.

Exercises #

  1. Enable the React Compiler in the example above (for Vite, add babel.plugins: [["babel-plugin-react-compiler"]] to @vitejs/plugin-react). After enabling, confirm that even when you remove both memo and useCallback, clicking the counter button does not re-render the 1000 items. That is the result of the compiler applying memoization automatically.
  2. Find a case where useMemo actually helps. Build a component that sorts 10,000 items every time, and confirm that, without useMemo, even unrelated state changes trigger a re-sort. Apply useMemo(() => [...items].sort(...), [items]) and measure the difference with the React DevTools Profiler.
  3. Stabilizing an effect’s dependency. Build a case where a child component receives a config object from its parent and runs useEffect(() => ..., [config]). If the parent recreates config every render, the child’s effect runs every render. Stabilize it with useMemo and confirm the effect only runs once.

In one line: React.memo skips a component re-render when props are the same. useMemo caches a computed value or stabilizes object / array references. useCallback stabilizes a function reference (the function-only shorthand of useMemo). The default is not to use them — add them only when measurement actually shows it’s slow. The three need to be used as a set to take effect (memo + stabilized props). When React 19’s React Compiler is enabled, you write them by hand far less, but the underlying principles remain valid.

Next chapter #

So far we have covered what happens inside one page. A real web app usually has multiple screens. When the user clicks a menu, the screen changes, the URL changes, and the back button has to work. The next chapter, Chapter 15 Routing overview, covers the concept of SPA routing and the basics of React Router, and continues into a comparison with the App Router from Part 4 (Modern Next.js). It is a way of building an early bridge from Part 2 to Part 4.

X