Quick Answer

React.memo skips re-rendering a component when its props are shallow-equal to last time. useMemo caches a computed value between renders, and useCallback caches a function's identity. All three compare by reference: memo compares the props, the two hooks compare their dependency arrays. So passing a new object, array, arrow function or inline JSX defeats memo completely and you keep the cost of comparing with none of the benefit. Profile with React DevTools before adding any of them.

Why React.memo silently does nothing

A list of students renders slowly, so you wrap the row in React.memo, reload, and the profiler shows every row still re-rendering. The memo is not broken. It is being defeated by the props.

const Row = React.memo(function Row({ student, style, onSelect }) {
  return <li style={style} onClick={onSelect}>{student.name}</li>;
});

function StudentList({ students }) {
  const [query, setQuery] = useState("");   // any keystroke re-renders every Row
  return students.filter((s) => s.name.includes(query)).map((s) => (
    <Row
      key={s.id}
      student={s}
      style={{ padding: 8 }}                 // new object every render
      onSelect={() => console.log(s.id)}     // new function every render
    />
  ));
}

React.memo compares each prop with the previous one using Object.is, which is reference equality for objects. Two object literals with identical contents are not the same object. { padding: 8 } is created fresh on every render of StudentList, and so is the arrow function. Both comparisons fail, so memo re-renders the row anyway, and you have added a per-prop comparison for nothing.

The same applies to arrays (tags={[]}), to inline objects built by .filter() or .map(), and to JSX passed as a prop. Every one of those is a new reference each render.

The fix is to stop creating new references. Constants that never change move outside the component entirely. Handlers become stable and take an argument instead of closing over one:

const rowStyle = { padding: 8 };   // module scope: same object forever

const handleSelect = useCallback((id) => console.log(id), []);

<Row key={s.id} student={s} style={rowStyle} onSelect={handleSelect} />
// inside Row: onClick={() => onSelect(student.id)}

Now the arrow function lives inside Row, where creating it is free, and the props that cross the memo boundary are stable.

What each of the three actually does

These three get lumped together, but they solve different problems and are not interchangeable.

React.memo(Component) wraps a component. Before re-rendering it, React shallow-compares the new props with the old ones and skips the render if they all match. It is about a component. It takes an optional second argument, a custom comparison function, and the direction catches people out: return true when the props are equal and the render should be skipped. That is the opposite of shouldComponentUpdate, and inverting it silently disables the memo.

useMemo(fn, deps) caches a value. React runs fn and remembers the result; on later renders, if every entry in deps is reference-equal to last time, it returns the cached value instead of calling fn again. It is about a computation, or about keeping an object identity stable.

useCallback(fn, deps) caches a function. It is exactly useMemo(() => fn, deps) with nicer syntax. It does not make the function faster; it keeps the same function object across renders so that a memoised child or an effect's dependency array sees no change.

// value: recomputed only when students or city change
const filtered = useMemo(
  () => students.filter((s) => s.city === city),
  [students, city]
);

// identity: the same function object while userId is unchanged
const save = useCallback((data) => api.save(userId, data), [userId]);

The rule that ties them together: useMemo and useCallback are pointless unless something downstream compares by reference. That something is either a memoised child, a dependency array, or an expensive computation you are avoiding. If none of those apply, you have added a deps array to maintain and gained nothing.

Treat all three as a cache, not a guarantee. React is allowed to throw away memoised values, so code must stay correct if the function runs again.

Profile before you memoise anything

Most performance work in React starts from a guess, and the guess is usually wrong. Install the React DevTools browser extension, open the Profiler tab, hit record, do the slow interaction, and stop. You get a flame chart of what rendered and how long each component took.

Turn on the setting that records why each component rendered. It tells you whether a render was caused by a prop change, a state change, a context change or a parent re-render. That one line usually points at the real cause, and it is often not the component you suspected.

Two ideas save a lot of wasted effort. First, a re-render is not a DOM update. React re-running your function and diffing the output is cheap for a simple component; the expensive parts are large trees, heavy computation inside render, and actual DOM mutations. A component that re-renders often but returns the same output costs very little.

Second, look at what dominates the flame chart. If one row takes a fraction of a millisecond and there are five thousand rows, the fix is not memo, it is windowing so you only render the fifty rows on screen. If a single component takes most of the frame, look inside it: a sort or a JSON parse in the render body is a far bigger win than any wrapper.

Also profile a production build. Development React includes extra checks and StrictMode deliberately renders components twice, so development timings exaggerate everything and can send you chasing a problem that does not exist for users.

Finally, check the network and the images before the JavaScript. On a college wifi connection, an unoptimised hero image usually hurts more than any render.

The patterns that genuinely pay off

An expensive computation on every keystroke. Sorting or filtering thousands of records inside the render body reruns whenever any state changes, including unrelated state. useMemo is the right tool.

const sorted = useMemo(
  () => [...students].sort((a, b) => b.score - a.score),
  [students]
);

Note the copy. sort mutates in place, so sorting students directly changes the prop or state array and can break memo comparisons elsewhere.

A context value object. Every consumer of a context re-renders when its value changes by reference, and value={{ user, logout }} is a new object on every render of the provider. Memoise it:

const value = useMemo(() => ({ user, logout }), [user, logout]);
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>

Keeping an effect from looping. If an effect depends on a function or object created in render, it reruns every render. useCallback stabilises the function so the effect runs only when it should. This is a correctness fix as much as a performance one.

Moving state down instead of memoising up. Often the real problem is that a text input at the top of a page keeps state that only one small component needs, so every keystroke re-renders the whole page. Move the input and its state into their own component and the problem disappears with no memo at all. Similarly, passing a heavy subtree as children means it is created by the parent and not re-created when the wrapper's own state changes.

That last idea is worth internalising: better component boundaries remove re-renders, memoisation only skips them.

When memoising makes things worse

None of these is free. React.memo adds a prop comparison on every render. useMemo and useCallback allocate and store a value plus a dependency array, and keep the old values alive in memory until the component unmounts. For a component that renders a heading and a paragraph, the bookkeeping can genuinely cost more than the render you skipped.

Specific cases where the wrapper is a net loss:

  • Deps that change every render. useMemo(() => f(config), [config]) where config is an object literal recreated each render never hits the cache, and you pay the comparison every time.
  • A memoised component that always receives new children. JSX children are a new element object each render, so the shallow compare always fails.
  • Cheap components. Wrapping a button in memo to skip a render that takes microseconds is measurement theatre.
  • Memoising to cover for a missing key. A list whose items use the array index as a key re-mounts rows when items are inserted or reordered. Fix the key; memo will not help.

There is also a correctness trap. A dependency array that is missing something gives you a stale value that is very hard to debug: a callback that keeps sending last week's user ID because userId was left out of the deps. Let the ESLint exhaustive-deps rule tell you what belongs there, and if the honest deps list makes the memo useless, that is the memo telling you it was never going to help.

Newer React tooling can insert this memoisation automatically at build time, which removes most of the need to write it by hand. Whether that applies to your project depends on your setup, so check before assuming either way. The advice that does not change is the order of operations: measure, fix the structure, and reach for memo last.

Frequently Asked Questions

Why does React.memo not stop my component re-rendering? Almost always because at least one prop is a new reference each render. Object literals, array literals, arrow functions and JSX passed as props are recreated every time the parent renders, and memo compares props by reference. Move constants to module scope, wrap handlers in useCallback, memoise derived objects with useMemo, or restructure so the child creates the callback itself from a stable prop.
What is the difference between useMemo and useCallback? useMemo caches the result of calling a function, while useCallback caches the function itself. useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Use useMemo when a calculation is expensive or when you need a stable object identity, and useCallback when you are passing a function to a memoised child or listing it in a dependency array.
Should I wrap every component in React.memo? No. Each wrapper adds a shallow prop comparison and keeps previous props in memory, and for cheap components that costs more than the render it avoids. It also makes the code noisier and adds dependency arrays that can go stale. Apply memo where the profiler shows a genuine problem, typically large lists, expensive subtrees, or components that re-render often with unchanged props.
Does a re-render mean the DOM is updated? No, and this confusion causes most premature optimisation. A re-render means React ran your component function again and compared the resulting element tree with the previous one. If the output is the same, no DOM changes are made. Re-renders are usually cheap; the expensive parts are heavy computation inside the render body, very large trees, and actual DOM mutations or layout work.
How do I find what is actually slow? Use the React DevTools Profiler, record the slow interaction, and read the flame chart together with the setting that reports why each component rendered. Profile a production build, since development mode adds checks and StrictMode renders components twice. Check network and image weight too, because on a slow connection those often dominate the experience far more than any render cost.