What you'll learn
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])whereconfigis 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.
