Skip to content
elephantoo

useMemo, useCallback & memo

Lesson 19 of 30 16 min read

How re-rendering works, when memoisation helps, and what the React Compiler does for you.


React is fast by default, but every state change re-renders a component and all of its children. Usually that's cheap. Occasionally a component does expensive work or a large list re-renders on every keystroke, and you want to skip unnecessary work. That's what memoisation is for: remembering a previous result and reusing it when the inputs haven't changed.

React gives you three tools — useMemo, useCallback and memo — and the React Compiler can apply them automatically.

First: measure, don't guess#

Before optimising, confirm there's a problem. Use the React DevTools Profiler (covered in Performance & DevTools) or a quick timer:

JSX
console.time("filter");
const visible = filterTodos(todos, tab);
console.timeEnd("filter");
Output
filter: 0.12ms

If it takes well under a millisecond, memoising won't help. As a rule of thumb, look at anything that takes more than ~1 ms or causes visible lag while typing. Test with a production build and CPU throttling in DevTools — development mode is slower.

useMemo: cache a calculation#

JSX
import { useMemo, useState } from "react";

function slowFilter(products, query) {
  const start = performance.now();
  while (performance.now() - start < 50) {
    // pretend this is expensive (50 ms)
  }
  return products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()));
}

export default function ProductPage({ products }) {
  const [query, setQuery] = useState("");
  const [dark, setDark] = useState(false);

  // Only re-filter when products or query change – not when `dark` toggles
  const visible = useMemo(() => slowFilter(products, query), [products, query]);

  return (
    <div className={dark ? "dark" : ""}>
      <button onClick={() => setDark((d) => !d)}>Toggle theme</button>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>{visible.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
    </div>
  );
}

Without useMemo, toggling the theme would also run the 50 ms filter. With it, React compares the dependencies (products, query) using Object.is; if they're the same as last render, it returns the cached result.

The calculation function must be pure — no side effects, just compute and return.

memo: skip re-rendering a component#

memo wraps a component so it only re-renders when its props change:

JSX
import { memo, useState } from "react";

const ProductList = memo(function ProductList({ items }) {
  console.log("ProductList rendered");
  return (
    <ul>
      {items.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
});

function Shop({ items }) {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(count + 1)}>Clicked {count}</button>
      <ProductList items={items} /> {/* doesn't re-render on click: `items` is unchanged */}
    </>
  );
}

A memoised component still re-renders when its own state or a context it uses changes.

The referential equality trap#

Here's where people get stuck. This memo does nothing:

JSX
function Shop({ items }) {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(count + 1)}>Clicked {count}</button>
      <ProductList
        items={items.filter((p) => p.inStock)} // ❌ new array every render
        onSelect={(id) => console.log(id)} // ❌ new function every render
        style={{ gap: 8 }} // ❌ new object every render
      />
    </>
  );
}

[] !== [] and (() => {}) !== (() => {}) in JavaScript. Every render creates new values, so memo's comparison always fails:

JavaScript
console.log([1, 2] === [1, 2]); // false
const f = () => {};
console.log(f === f); // true – same reference
Output
false
true

The fix is to keep those values stable between renders.

useCallback: cache a function#

useCallback(fn, deps) returns the same function until a dependency changes. It's exactly useMemo(() => fn, deps):

JSX
import { memo, useCallback, useMemo, useState } from "react";

const ProductList = memo(function ProductList({ items, onSelect }) {
  return (
    <ul>
      {items.map((p) => (
        <li key={p.id}>
          <button onClick={() => onSelect(p.id)}>{p.name}</button>
        </li>
      ))}
    </ul>
  );
});

export default function Shop({ items }) {
  const [count, setCount] = useState(0);
  const [selectedId, setSelectedId] = useState(null);

  const inStock = useMemo(() => items.filter((p) => p.inStock), [items]);
  const handleSelect = useCallback((id) => setSelectedId(id), []); // setter is stable

  return (
    <>
      <button onClick={() => setCount(count + 1)}>Clicked {count}</button>
      <p>Selected: {selectedId ?? "none"}</p>
      <ProductList items={inStock} onSelect={handleSelect} />
    </>
  );
}

Now clicking the counter doesn't re-render ProductList — both props are the same references as before.

Tip: if your callback needs the current state, use an updater function (setItems(prev => ...)) so you can leave the state out of the dependency array and keep the callback stable.

Stable effect dependencies#

Memoisation also matters for effects. An object or function created during render changes every time, so an effect that depends on it runs every render:

JSX
function ChatRoom({ roomId }) {
  // ❌ new object each render → effect reconnects on every render
  const options = { serverUrl: "wss://chat.example.com", roomId };

  useEffect(() => {
    const conn = createConnection(options);
    conn.connect();
    return () => conn.disconnect();
  }, [options]);
}

Often the best fix isn't useMemo at all — move the object inside the effect and depend on the primitive:

JSX
function ChatRoom({ roomId }) {
  useEffect(() => {
    const conn = createConnection({ serverUrl: "wss://chat.example.com", roomId });
    conn.connect();
    return () => conn.disconnect();
  }, [roomId]); // ✅ only reconnects when the room changes
}

Cheaper alternatives to memoising#

Before memoising, try restructuring:

  1. Move state down. If only a small part of the page uses some state, put that state in a smaller component so fewer things re-render.
  2. Pass children. A component that receives JSX as children doesn't re-render that JSX when its own state changes:
JSX
function ScrollTracker({ children }) {
  const [y, setY] = useState(0);
  useEffect(() => {
    const onScroll = () => setY(window.scrollY);
    window.addEventListener("scroll", onScroll);
    return () => window.removeEventListener("scroll", onScroll);
  }, []);
  return (
    <>
      <div className="scroll-indicator">{Math.round(y)}px</div>
      {children} {/* created by the parent – not re-rendered on scroll */}
    </>
  );
}

function App() {
  return (
    <ScrollTracker>
      <VeryExpensiveArticle />
    </ScrollTracker>
  );
}
  1. Keep renders pure and avoid unnecessary effects that set state.
  2. Virtualise long lists (render only visible rows) with a library like TanStack Virtual.

The React Compiler#

Manually adding useMemo, useCallback and memo is tedious and easy to get wrong. React Compiler (stable since v1.0) is a build-time tool that analyses your components and adds memoisation automatically — often more precisely than you would by hand.

In a Vite project, install the Babel plugin and add React's preset:

Terminal
npm install -D babel-plugin-react-compiler @babel/core @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()] })],
});

Next.js has a reactCompiler: true option, and Expo enables it by default in new apps. With the compiler on:

  • You can write plain components and rely on it for most memoisation.
  • Your code must follow the Rules of React — pure renders, no mutating props/state, hooks at the top level. The eslint-plugin-react-hooks recommended config includes compiler-powered lint rules that flag violations.
  • Components marked with a ✨ "Memo" badge in React DevTools have been optimised.
  • Existing useMemo/useCallback calls are fine to leave in place; the compiler works alongside them.

When to memoise manually#

Even with the compiler, it helps to understand memoisation. Reach for it when:

  • A calculation is measurably slow (> ~1 ms) and its inputs rarely change.
  • You pass props to a memo component and need them to be stable.
  • A value is used as an effect dependency and must not change every render.
  • You're writing a custom hook that returns functions or objects others will depend on.

Don't wrap everything "just in case" — memoising has its own cost and makes code harder to read.

Common mistakes#

  • Memoising cheap calculations, adding complexity for no gain.
  • Wrapping a component in memo but passing it new inline objects/functions every render.
  • Leaving dependencies out of the array, causing stale values (let the linter help).
  • Using useMemo for side effects — use useEffect instead.
  • Expecting useMemo to be a guaranteed cache; React may discard it (e.g. during development). It's a performance hint, not a storage mechanism.

What's next#

Sometimes components don't just render slowly — they crash. Next: error boundaries, so one broken widget doesn't take down the whole page.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What does useMemo(() => compute(a, b), [a, b]) do?

  2. 2.A child is wrapped in memo, but it still re-renders every time the parent renders. A likely cause?

  3. 3.What does the React Compiler do?

Finished reading?

Mark this lesson complete to track your progress.