React · State at Scale

Memoization

Memoization means “remember the last result and reuse it if the inputs did not change”. React has three tools for it — useful in the right places, noise everywhere else.

Intermediate 6 min Lesson 29 of 42Examples target React 19 with Vite.
Before you start:Performance Optimization
0/42 in React
On this page · 6 min · Intermediate

Learning objectives#

  • Skip re-rendering a component with memo
  • Cache an expensive calculation with useMemo
  • Keep a function identity stable with useCallback
  • Know when memoization is not worth it

Explanation#

memo(Component) re-renders the component only when its props change (compared shallowly with Object.is).

useMemo(() => compute(a, b), [a, b]) recalculates only when a or b changes. Use it for genuinely expensive work, like filtering 50,000 rows.

useCallback(fn, deps) returns the same function between renders. It matters when the function is a prop of a memo component or a dependency of an effect.

memo is defeated by new objects and functions created on every render: <Row style={{}} onClick={() => ...} /> always has “new” props. That is why memo, useMemo and useCallback are often used together.

The React Compiler (available for React 19) memoizes automatically at build time. In projects that use it, you rarely write these hooks by hand.

Syntax#

JSX Illustrative
const Row = memo(function Row({ item, onSelect }) { ... });


const visible = useMemo(() => items.filter(matches), [items, query]);
const handleSelect = useCallback(id => setSelected(id), []);

Code example: Filtering a big list without wasted work#

JSXBigList.jsx Runnable
import { memo, useCallback, useMemo, useState } from 'react';


const ITEMS = Array.from({ length: 5000 }, (_, i) => ({ id: i, name: `Product ${i}`, price: (i * 37) % 1000 }));


let rowRenders = 0;
const Row = memo(function Row({ item, selected, onSelect }) {
  rowRenders += 1;
  return (
    <li onClick={() => onSelect(item.id)} style={{ fontWeight: selected ? 'bold' : 'normal' }}>
      {item.name} — ₹{item.price}
    </li>
  );
});


export default function BigList() {
  const [query, setQuery] = useState('');
  const [selectedId, setSelectedId] = useState(null);
  const [darkMode, setDarkMode] = useState(false);


  const visible = useMemo(                                   // re-filter only when query changes
    () => ITEMS.filter(i => i.name.includes(query)).slice(0, 50),
    [query],
  );


  const handleSelect = useCallback(id => setSelectedId(id), []);   // same function every render


  return (
    <div style={{ background: darkMode ? '#111' : '#fff', color: darkMode ? '#eee' : '#111' }}>
      <input value={query} onChange={e => setQuery(e.target.value)} aria-label="Filter" />
      <label><input type="checkbox" checked={darkMode} onChange={e => setDarkMode(e.target.checked)} /> Dark</label>
      <ul>
        {visible.map(item => (
          <Row key={item.id} item={item} selected={item.id === selectedId} onSelect={handleSelect} />
        ))}
      </ul>
      <small>Row renders so far: {rowRenders}</small>
    </div>
  );
}
Expected output
The first render draws 50 rows. Toggling “Dark” re-renders BigList but none of the 50 Row components (their props did not change) and the filter does not re-run. Selecting a row re-renders only the two rows whose selected prop changed. Typing in the filter re-runs the filter once per change.

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. Row is wrapped in memo, so it skips rendering when its props are unchanged.
  2. useMemo caches the filtered list; toggling dark mode does not recompute it.
  3. useCallback keeps handleSelect identical across renders, so memo sees the same onSelect prop.
  4. Without useCallback, a new arrow function every render would make every Row re-render.

Real-world use case#

A trading screen shows 2,000 instruments updating every second. Memoised rows that re-render only when their own price changes keep the UI smooth.

Common mistakes#

Memoising everything

Each memo/useMemo has a cost and makes code harder to read. Use them where the Profiler shows a problem.

memo with inline objects or functions

style={{}} or onClick={() => …} are new every render, so memo never skips. Stabilise them or restructure.

Missing dependencies

useMemo(() => filter(items, query), [items]) ignores query changes and shows stale data. List every input.

Best practices#

  • Profile first, memoise second
  • Combine memo with stable props
  • Complete dependency arrays (use the ESLint rule)
  • Consider the React Compiler

Remember this#

memo = skip re-render if props are equal.

useMemo = cache a value. useCallback = cache a function.

New object/function each render defeats memo.

Practice exercise#

Your task: A component computes the total of a 10,000-item cart on every render, even when only a "notes" text field changes. Cache the total with useMemo.

Need a hint?
useMemo(() => items.reduce(...), [items]).
Show solution
JSX Runnable
import { useMemo, useState } from 'react';


const CART = Array.from({ length: 10000 }, (_, i) => ({ id: i, price: 10, qty: 1 }));


export default function CartTotal() {
  const [notes, setNotes] = useState('');
  const total = useMemo(() => CART.reduce((sum, i) => sum + i.price * i.qty, 0), []);
  return (
    <>
      <p>Total: ₹{total}</p>   {/* ₹100000 */}
      <textarea value={notes} onChange={e => setNotes(e.target.value)} aria-label="Notes" />
    </>
  );
}

Mini quiz#

Memoization quizMultiple choice Question 1 of 3

What does useCallback return?

Score so far: 0/0

Want more? Practise all React MCQs · React interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.