React Performance: memo, useMemo, useCallback and When Not to Use Them

React.js Full Course: Beginner to Advanced · Advanced React · 9 min read · Updated

Skip needless renders

  1. Typing in a parent redraws a big child every time, even if the child did not change.
  2. First measure with the Profiler. Then try the simplest fix: move the state down into a small component.
  3. If the child is still slow, wrap it in memo so React skips it when its props are the same.
  4. Objects and functions are new on every render, so keep them steady with useMemo and useCallback.
  5. Do not add these everywhere. Measure first, and fix only what is really slow.

Most React apps are fast enough with no special work. But sometimes a page feels slow: typing lags, a list stutters, or a click takes a moment. This lesson explains why that happens and how to fix it with a few simple tools. It also explains the most important rule: measure first, then optimise.

Step 1: find the real problem

Do not add memo and useMemo everywhere “just in case”. They add code and need care, and often change nothing. Find the slow part first:

  • Open React Developer Tools and use the Profiler tab. Click record, do the slow action, stop, and look at which components rendered and how long each took.
  • Test with CPU throttling in the browser tools (Performance tab), because your computer is faster than many visitors' phones.
  • Look at the Network tab. Many slow pages are slow because of large images, large JavaScript files or slow requests, not because of React.

A common order of fixes, from most to least effective: send less data and less code first (smaller images, code splitting), then make the work smaller (show only what is visible), and only then skip renders with memo.

Why components render too much

From the last lesson: when a component renders, its children render too, even if their props did not change. Usually that is cheap. But if a child is slow (a big chart or a long list), re-rendering it every time the parent changes is wasteful. For example, typing in a text box in the parent redraws the whole big list on every key press.

function Page() {
  const [text, setText] = useState('');
  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <BigList />     {/* renders again on every key press */}
    </div>
  );
}

Fix 1: move state down

The best and simplest fix needs no tools. Put the state in a smaller component that only contains the part that needs it:

function SearchBox() {
  const [text, setText] = useState('');
  return <input value={text} onChange={(e) => setText(e.target.value)} />;
}

function Page() {
  return (
    <div>
      <SearchBox />
      <BigList />     {/* no longer renders when typing */}
    </div>
  );
}

Now typing only redraws SearchBox. Another version of this trick is to pass the slow part in as children, since children made by the parent above are not redrawn when the small component's state changes.

Fix 2: React.memo

memo wraps a component so React skips rendering it when its props are the same as last time.

import { memo } from 'react';

const BigList = memo(function BigList({ items }) {
  return (
    <ul>
      {items.map((item) => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
});

React compares each prop with the old one using “is it the same value?”. For numbers and strings that is clear. For objects, arrays and functions, a new one is made on every render, so they are never the same as before, even if they look the same. That makes memo useless unless you keep those props steady. That is the job of the next two hooks.

Fix 3: useMemo for expensive work

useMemo remembers the result of a calculation, and recalculates only when its dependencies change.

function ProductList({ products, query }) {
  const visible = useMemo(() => {
    return products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()));
  }, [products, query]);

  return <ul>{visible.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}

If another state in this component changes, visible is not recalculated, because products and query are the same. It is useful for really heavy work, such as filtering thousands of items, and for keeping an object or array steady so that a memo child does not redraw.

// Keep an object steady for a memo child
const style = useMemo(() => ({ color: theme === 'dark' ? 'white' : 'black' }), [theme]);
return <MemoChild style={style} />;

Fix 4: useCallback for steady functions

useCallback is the same as useMemo, but for functions. It gives the same function again unless a dependency changes:

const MemoRow = memo(function Row({ item, onRemove }) {
  return <li>{item.name} <button onClick={() => onRemove(item.id)}>x</button></li>;
});

function List() {
  const [items, setItems] = useState(initialItems);

  const handleRemove = useCallback((id) => {
    setItems((current) => current.filter((item) => item.id !== id));
  }, []);

  return <ul>{items.map((item) => <MemoRow key={item.id} item={item} onRemove={handleRemove} />)}</ul>;
}

Notice we used the function form of setItems, so the callback does not need items in its dependencies, and it stays the same forever. Without useCallback, handleRemove would be a new function every render, and memo on MemoRow would do nothing.

The React Compiler

React now has a compiler, a build tool that adds this kind of memoisation automatically. When it is turned on in your project, you will need to write useMemo, useCallback and memo much less. It is still good to know what they do, because you will meet them in existing code. Check the official React docs to see how to enable the compiler in your tool, as the steps change.

Fix 5: show less (virtualise long lists)

If a list has thousands of rows, even fast components are slow, because the browser has to make thousands of elements. The fix is to draw only the rows you can see, plus a few extra. This is called virtualisation or windowing. Libraries such as react-window and TanStack Virtual do it for you. For lists of a few hundred rows, you do not need it. Pagination (showing 20 at a time) is simpler and also good for users.

Fix 6: do not do work on every key press

Searching or filtering on every key press can be heavy. Two helpers:

  • Debounce: wait until the person stops typing for a moment (see useDebounce in lesson 17).
  • `useDeferredValue`: a React hook that lets the input stay quick while the slow list catches up a little later.
import { useDeferredValue } from 'react';

function Search({ items }) {
  const [text, setText] = useState('');
  const deferred = useDeferredValue(text);
  const results = items.filter((i) => i.name.includes(deferred));
  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <ul>{results.map((i) => <li key={i.id}>{i.name}</li>)}</ul>
    </div>
  );
}

The input updates at once. The list uses the slightly older deferred value, and React updates it when it has time, so typing never feels stuck.

Other quick wins

  • Images: use modern sizes and formats, set width and height, and add loading="lazy" for images below the fold.
  • Code splitting: load a page's code only when needed (next lesson).
  • Keys: stable keys avoid recreating list items.
  • Avoid heavy work while rendering: do not sort or parse big data in the body without useMemo, if it is truly big.
  • Production build: always test speed with npm run build and npm run preview. The development version is much slower on purpose.

A simple decision list

  1. Is the app really slow? If not, stop.
  2. Find the slow part with the Profiler.
  3. Can you move state down, or pass the slow part as children? Do that first.
  4. Is a heavy child redrawing with the same props? Add memo, and make object and function props steady.
  5. Is a calculation heavy? Use useMemo.
  6. Is a list very long? Paginate or virtualise.

Common mistakes

  • Adding memo, useMemo and useCallback everywhere before measuring.
  • Wrapping a component in memo but passing a new object or function each time, so it never skips.
  • Wrong dependency arrays in useMemo and useCallback, which give old values.
  • Measuring speed in the development build.
  • Using the array index as a key in long lists that change.

Your task

  1. Make a page with a text box and a list of 2,000 items. Notice the lag when typing, and fix it by moving the state down.
  2. Wrap the list item in memo, add useCallback for a remove button, and check with the Profiler that rows no longer redraw.
  3. Try useDeferredValue for a filter box.
  4. Run the Profiler before and after each change and write down the difference.

How to check it worked

  • Typing in the box does not make the whole list render in the Profiler.
  • Removing one item redraws only the changed parts.
  • You can say why memo needs steady props.

Summary

  • Measure first with the Profiler. Many slow pages are slow because of big images and big code, not renders.
  • The simplest fix is to move state down. Then use memo, useMemo and useCallback where they really help.
  • memo needs steady props. Objects and functions must be kept steady with useMemo and useCallback.
  • For very long lists, paginate or virtualise. Use debounce or useDeferredValue for typing.

Frequently asked questions

When should I use useMemo and useCallback?

When a value must stay steady for a memo component or a dependency list, or when a calculation is truly heavy. Not by default.

Does React.memo always make my app faster?

No. It only helps if the component is slow and its props are the same between renders. New objects and functions as props break it.

Why is my app slower in development?

The development build adds extra checks and runs some code twice. Test speed with the production build.

What is virtualisation?

Drawing only the list rows that are visible, so very long lists stay fast. Libraries such as react-window do it.