React Basics #14: Performance optimization (memo / useMemo / useCallback)

9 min read

Last time we covered custom hooks, the tool for reusing logic. This time we’ll cover three tools used to make a React app run fastReact.memo, useMemo, and useCallback. These are powerful tools but often misused, so understanding when to use them and when not to is just as important as how they work.

First — React is fast by default #

This is the very first thing to mention. React uses a virtual DOM and applies only the actual changes to the DOM, so a typical app runs fast enough without any special optimization.

Before you start optimizing, ask:

  • Is it really slow? (Does it feel slow? Have you measured it?)
  • Where is it slow? (Use React DevTools’ Profiler to find out)
  • What needs to happen to make that part faster?

The tools in this article are for when re-renders happen so often or so heavily that they truly become a problem — not a preventive coat of paint to apply to every component. Keep this in mind as we begin.

A recap of React’s re-render model #

To understand how the three tools work, you need to know when re-renders happen.

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

Whether the props passed to a child are the same or different, the child component function is called again anyway. If that’s not expensive, no problem. But if a child does heavy computation, or its children do, the cumulative cost can grow.

memo/useMemo/useCallback are all tools to reduce these “unnecessary re-renders/computations.”

React.memo — let a child skip re-rendering #

React.memo wraps a component and makes it skip re-rendering when the props are identical to the previous ones.

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

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

export default memo(Heavy);

A memo-wrapped Heavy doesn’t re-render itself as long as the value prop is the same, even when the parent re-renders; it just reuses the previous output.

Pitfall 1 — object/array/function props become “new values” each render #

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

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

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

Every time the parent re-renders, the object literal { color: 'red' } is created fresh. The contents are the same but the reference is different each time, so memo’s comparison says “different prop” and the child re-renders every time. As a result, memo is rendered useless.

The tools that solve this are useMemo and useCallback.

useMemo — memoize a value #

useMemo caches a computed result and returns it unchanged when the dependencies haven’t changed, 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} />
    </>
  );
}

The shape of useMemo is similar to useEffect.

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

In the example above, config has an empty dependency array, so it always returns the same object reference. So the Heavy component (wrapped in memo) doesn’t re-render every time.

useMemo serves two purposes #

useMemo is useful for two reasons.

(1) Cache the result of an expensive computation

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

If items and threshold haven’t changed, filter doesn’t run again. Meaningful savings when the data is large and filtering/sorting is expensive.

(2) Stabilize an object/array reference (when used with memo)

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

The purpose is to keep the reference stable when passing an object/array to a memo-wrapped child.

useCallback — memoize a function #

useCallback is essentially a function-only shortcut for useMemo.

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

It removes the awkwardness of an arrow function returning another arrow function. What it does is keep the function reference stable as long as the dependencies don’t change.

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

when the child is memo-wrapped and receives a function as a 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 would be created every render and the memo-wrapped MemoizedChild would re-render needlessly.

Summary of the three tools #

ToolWhat it memoizesWhen to use it
memoThe component itselfThe parent re-renders often, the child’s props rarely change, and the child is expensive
useMemoA computed result (value)Expensive computation, or stabilizing object/array references for memo children
useCallbackA functionWhen passing a handler to a memo-wrapped child

useMemo and useCallback are almost meaningless on their own. They become meaningful when paired with a memo-wrapped child (or when used as effect dependencies).

Common misconceptions and pitfalls #

Misconception 1. “Using these always makes things faster” #

Quite the opposite. useMemo/useCallback aren’t free — there’s a cost to comparing dependencies and storing previous values. If the savings are smaller than that cost, they can actually slow things down.

The default stance the React docs clearly recommend:

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

Misconception 2. “Just slap on memo and re-renders go away” #

As shown above, if you receive object/array/function props you must stabilize them as well for memo to take effect. Slapping on memo and stopping there is usually pointless.

Misconception 3. “Same props means it definitely won’t re-render” #

memo does a shallow comparison. It doesn’t peek deep into objects; it only compares references at the top level. So if a new object comes in every time (and isn’t stabilized), it judges them different.

Also, if the child component owns its own state via useState or subscribes to a context via useContext, a state change inside the child still triggers a re-render even with identical props. memo only blocks re-renders triggered by the parent.

So when should you use them? #

These are meaningful in situations like the following.

  1. Each item of a list is a heavy component — if changing one item re-renders all items, the cost is large. Wrap each item with memo, and stabilize handlers passed by the parent with useCallback
  2. The computation cost is genuinely high — for example, sorting/filtering/aggregating tens of thousands of items. Cache with useMemo so the same input doesn’t recompute
  3. An effect’s dependency is a function/object — a new reference every time means the effect runs every time. Stabilize with useCallback/useMemo

For small components, light computations, and static screens, they’re rarely needed. They only complicate the code.

Tip
The React Compiler introduced in React 19 is an experimental tool that applies these memoizations automatically at compile time. Once it stabilizes, we’ll likely have very few opportunities to write memo/useMemo/useCallback by hand. Even so, you should understand the underlying principles to debug what the compiler outputs, so this article’s content remains relevant.

Measure first #

The first step in performance optimization is always measurement. The Profiler tab in React DevTools lets you see how often and how long renders take.

  1. Install the React Developer Tools browser extension
  2. DevTools → “Profiler” tab
  3. Red record button → perform the slow-feeling action → stop
  4. Inspect the bar chart of which components rendered and for how long

If you skip this and start optimizing based on a hunch about where things might be slow, you’ll often end up with the same bottleneck and more complex code.

Try it yourself #

Let’s experience memo’s effect with a large-list example.

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 press the counter button. You’ll see that all 1000 ListItems do not re-render; you’ll get 1000 logs only on the first mount, and almost no extra logs as you increment the counter. When you click an item, only that item (or up to two — that one plus the previously selected one) re-renders.

If you want to compare directly:

  • Change export default memo(ListItem); to export default ListItem; → 1000 render logs every time you press the counter
  • Remove useCallback and use onSelect={(id) => setSelected(id)} → 1000 renders even with memo (the function reference changes every time)

You can see how the three tools cooperate at a glance.

Wrapping up #

In this article we covered React’s three performance optimization tools.

  • React.memo — skip a component re-render when props are equal
  • useMemo — cache a computed result (or stabilize an object/array reference)
  • useCallback — stabilize a function reference (a function-only shortcut for useMemo)

The most important mindset:

  • The default is not using them — add only when measurement shows it’s truly slow
  • The three need to be used as a set for the effect to show (memo + stabilized props)
  • Measure first with React DevTools Profiler

So far we’ve covered things that happen on a single page, but real web apps usually have multiple screens. When the user clicks a menu, the screen changes, the URL changes, and the back button must work too. In the next article — and the last one in this series — “React Basics #15: Routing overview,” we’ll cover SPA routing concepts and React Router basics.

X