Memoization is the most over-applied tool in React code reviews. People wrap everything in useMemo and useCallback because it feels safe, and then the code is harder to read and no faster. It also comes up in nearly every React interview.
My rule is simple: memoize when you have measured a cost, not when you have imagined one. Here is what each tool does, where it silently fails, and what I try before reaching for it.
What each one does
useMemo caches a computed value between renders. React reruns your function only when a dependency changed, and otherwise hands back the previous result.
useCallback caches a function identity. useCallback(fn, deps) is the same idea as useMemo(() => fn, deps): you get the same function reference until a dependency changes.
React.memo wraps a component. When the parent re-renders, React compares the new props to the old ones with a shallow check (Object.is per prop). If every prop is equal, the child render is skipped.
import { useMemo, useState } from "react";
type Row = { id: string; name: string; score: number };
function Leaderboard({ rows }: { rows: Row[] }) {
const [query, setQuery] = useState("");
// Sorting 50k rows is real work. Typing in the input
// re-renders this component, but not the sort.
const sorted = useMemo(
() => [...rows].sort((a, b) => b.score - a.score),
[rows],
);
const visible = sorted
.filter((r) => r.name.includes(query))
.slice(0, 50);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>{visible.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
</>
);
}This is a fair use: the sort is expensive, the input changes often, and the sort only depends on rows. I would still profile it first, because a sort of 200 rows would not need any of this.
Try it yourself. The list below is filtered with a deliberately heavy computation. With the toggle off, every click on the unrelated counter recomputes it. With the toggle on, the counter leaves it alone.
useMemo on an expensive filter
Try this: click the unrelated counter a few times, then switch useMemo on and click it again. The first filter run happens on load.
The counter has nothing to do with the filter, but it still re-renders this component.
First load: the filter has to run once.
Matches: 3,000, showing the first 6.
- amber quartz 1272
- amber comet 336
- gentle meadow 2694
- dusty maple 459
- eager harbor 40
- fuzzy meadow 1397
Times are measured with performance.now() on your device. Typing changes the query, so it recomputes either way.
Why useCallback alone does nothing
Two things compare references: a React.memo child, and a hook that lists the function in its dependency array, such as useEffect. If neither exists, useCallback adds work and gives nothing back.
The pairing that works needs both halves. The child is memoized, and the function it receives is stable.
import { memo, useCallback, useState } from "react";
const Row = memo(function Row({
id,
onSelect,
}: {
id: string;
onSelect: (id: string) => void;
}) {
return <button onClick={() => onSelect(id)}>{id}</button>;
});
function List({ ids }: { ids: string[] }) {
const [selected, setSelected] = useState<string | null>(null);
const handleSelect = useCallback((id: string) => {
setSelected(id);
}, []);
return (
<>
<p>Selected: {selected}</p>
{ids.map((id) => <Row key={id} id={id} onSelect={handleSelect} />)}
</>
);
}Note that the setSelected setter is stable, so the empty dependency array is correct. Here, selecting a row re-renders List, but a Row whose id and onSelect are unchanged is skipped.
Flip the switches below and click Re-render parent. Only one combination skips the child.
memo + useCallback pairing
Try this: click Re-render parent with both switches off, then turn both on and click again. The child starts at 1 render (its first one).
parent renders: 0
child renders0
Why: No React.memo: the child re-renders whenever the parent does.
- Parent re-rendersstate changed
- Child Aprops equal, skipped
- Child Bprops equal, skipped
- Child Cprop changed, re-renders
Children with equal props are skipped. The one whose prop changed renders again.
Referential equality pitfalls
React.memo compares by reference, so a new object, array or function on every render counts as a changed prop. { a: 1 } is never equal to the { a: 1 } from the last render.
This is the classic bug: the child is memoized, the parent passes inline values, and memo never skips anything. You paid for the comparison and got nothing.
const Chart = memo(function Chart(props: {
options: { color: string };
onZoom: () => void;
}) {
/* ... */
return null;
});
function Dashboard() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{/* new object and new function on every render */}
<Chart options={{ color: "red" }} onZoom={() => console.log("zoom")} /> // [!code --]
</>
);
}// Static data: hoist it out of the component.
const options = { color: "red" };
function Dashboard() {
const [count, setCount] = useState(0);
const handleZoom = useCallback(() => {
console.log("zoom");
}, []);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Chart options={options} onZoom={handleZoom} /> // [!code ++]
</>
);
}If the object depends on props or state, useMemo is the tool for it. Static values do not need a hook at all, because moving them to module scope gives a stable reference for free.
When memoization is a waste
Memoization is not free. React stores the previous value and dependencies, then compares them on every render. For cheap work, that comparison can cost as much as the work you saved.
It also costs readability. Dependency arrays get stale, and a wrong one gives you a stale value that looks fine in review. A memoized child with a props shape that changes every render is pure overhead.
So the question is not whether a render happens. Renders are normal, and React is built for them. The question is whether a specific render is slow enough for a user to notice, and the React DevTools Profiler answers that.
Better first fixes
Before memoizing, I try two structural fixes. They remove the re-render instead of hiding it, and neither adds a hook.
The first is state colocation. If only one small part of the tree uses a piece of state, move the state down into that part, so the rest of the tree stops re-rendering when it changes.
The second is lifting content up with children. Elements created by a parent are not recreated when a wrapper component's own state changes, so React can reuse them.
import { useState, type ReactNode } from "react";
// State lives here, and so does the re-render.
function MouseTracker({ children }: { children: ReactNode }) {
const [x, setX] = useState(0);
return (
<div onMouseMove={(e) => setX(e.clientX)}>
<span>x: {x}</span>
{children}
</div>
);
}
function Page() {
return (
<MouseTracker>
{/* Created by Page, which does not re-render on mouse move.
Same element reference, so React skips it. */}
<ExpensiveTree />
</MouseTracker>
);
}- Is it slow?measured, not guessed
- Can I move state down?colocation
- Can I pass children?lift content up
- Only then memoizeuseMemo, useCallback, memo
Memoization is the last step, not the first.
The React Compiler
The React Compiler is a build-time tool, used through babel-plugin-react-compiler. It reached a stable 1.0 release in late 2025. It analyzes your components and hooks and adds memoization for you, at a finer grain than most people would write by hand.
That shifts the default. In new code I write plain components and let the compiler handle it, instead of wrapping things by reflex. It is also why the structural fixes above matter more now: they are the part the compiler does not do for you.
It has limits. It only optimizes code that follows the Rules of React, and it skips components it cannot analyze safely. It does not make slow algorithms fast, and it does not remove the need to profile.
Manual memoization does not disappear. It becomes an escape hatch, for example when you need a value or function to stay stable as an effect dependency. Existing useMemo and useCallback calls keep working, so there is no rush to rip them out of a working codebase.
Interview answer in 30 seconds
"useMemo caches a computed value, useCallback caches a function reference, and React.memo skips a child render when its props are shallow-equal. useCallback only helps if the child is memoized or the function is a hook dependency, because it keeps identity and does not speed anything up.
Inline objects and functions break React.memo, since they are new every render. I only memoize after profiling shows a real cost. Before that I try moving state down or passing children. And with the React Compiler, most manual memoization becomes unnecessary, so what is left is an escape hatch."
Key takeaways
- Memoize after measuring, not before.
useCallbackkeeps a reference stable. It does not make the function faster.useCallbackneeds aReact.memochild or a hook dependency to matter.- New objects, arrays and functions in props defeat
React.memo. - Try state colocation and
childrenbefore any memoization. - The React Compiler automates most of it, leaving manual hooks as escape hatches.
