Memoization is probably the most over-applied tool in React codebases, which is exactly why interviewers ask about it. They want to hear that you know why a component re-renders, what memo, useMemo and useCallback actually compare, and when adding them makes code slower to read without making it faster. The whole topic rests on one idea: these APIs skip work when their inputs haven’t changed, and “haven’t changed” means “is the same value or the same reference”.
Why components re-render
A component re-renders when:
- its own state changes,
- a context it reads changes, or
- its parent re-renders.
The third one surprises people. When a parent renders, React renders its children too, by default, whether or not their props changed. “Props changed” isn’t a trigger in its own right; the parent rendering is.
function App() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>Clicked {count}</button>
<Chart data={POINTS} />
</>
);
}Every click re-renders Chart, even though POINTS is a module-level constant that never changes. That’s usually fine. A re-render is React calling a function and diffing the result; if nothing changed, the DOM isn’t touched (see React Reconciliation & Keys). It only becomes a problem when the render itself is expensive, or when it happens for hundreds of components on every keystroke.
memo is a shallow comparison
Wrapping a component in memo lets React skip re-rendering it when its parent renders with the same props. React compares each prop with its previous value using Object.is. If every prop is equal, it reuses the last result.
const Chart = memo(function Chart({ data }: { data: Point[] }) {
// imagine an expensive render here
return <svg>{data.map((p) => <circle key={p.id} cx={p.x} cy={p.y} r={2} />)}</svg>;
});Now clicking the button in App no longer re-renders Chart. Two things still do: Chart’s own state changing, and a context it reads changing. memo only concerns props coming from the parent.
You can pass a custom comparison as the second argument, memo(Chart, (prev, next) => ...), returning true when the props are equal. It’s rarely worth it: it must account for every prop, including functions, and deep comparisons can cost more than the render you’re trying to skip.
Referential equality is the catch
Object.is compares primitives by value but objects, arrays and functions by reference. Two structurally identical objects are not equal:
Object.is(3, 3); // true
Object.is("a", "a"); // true
Object.is({}, {}); // false
Object.is([1, 2], [1, 2]); // false
Object.is(() => {}, () => {}); // false
const style = { color: "red" };
Object.is(style, style); // true: same referenceAnd every render creates fresh objects, arrays and functions for anything written inline. That quietly defeats memo:
function Dashboard({ points }: { points: Point[] }) {
const [tab, setTab] = useState("day");
return (
<>
<button onClick={() => setTab("week")}>Week ({tab})</button>
<Chart
data={points}
options={{ smooth: true }} // new object every render
onSelect={(p) => console.log(p)} // new function every render
/>
</>
);
}If Chart is memoized, it still re-renders on every tab change, because options and onSelect are never equal to their previous values. This is the gap useMemo and useCallback fill.
useMemo and useCallback
useMemo(calculate, deps) caches the result of a calculation between renders and recalculates only when a dependency changes (compared with Object.is). It has two jobs: skipping expensive work, and keeping a stable reference. useCallback(fn, deps) caches the function itself; it’s equivalent to useMemo(() => fn, deps).
function Dashboard({ points, range }: { points: Point[]; range: Range }) {
// 1. Skip expensive work unless points or range change
const visible = useMemo(() => filterByRange(points, range), [points, range]);
// 2. Keep references stable for a memoized child
const options = useMemo(() => ({ smooth: true, range }), [range]);
const handleSelect = useCallback((p: Point) => {
console.log("selected", p.id, "in", range);
}, [range]);
return <Chart data={visible} options={options} onSelect={handleSelect} />;
}useCallback doesn’t stop the inline function from being created; it hands back the cached one while the dependencies are unchanged. And if a value doesn’t depend on props or state at all, skip the hook and hoist it: const OPTIONS = { smooth: true } at module level is stable for free.
The same reference-stability argument applies to hook dependencies. An object that feeds a useEffect dependency array should be stable, or the effect will re-run every render (details in useEffect Pitfalls Interviewers Love to Ask).
Gotcha
Treat all three as performance hints, not guarantees. React may throw away a
useMemocache (for example, when you edit the file in development), and Strict Mode calls the calculation twice in development. If your code breaks without the memoization, fix that bug first.
When memoization is wasted
Memoization has a cost: extra code, dependency arrays to keep correct, and a comparison on every render. It’s wasted when:
- The child isn’t memoized.
useCallbackon a prop for a plain component does nothing for rendering; the child re-renders with its parent anyway. - One prop is always new. A single inline object, function or JSX prop (including
children, since<p>Hi</p>creates a new element object each render) makes thememocomparison fail every time. - The work is cheap. Filtering 50 items is not worth a cache.
- The dependencies change every render. A
useMemodepending on an object literal recalculates every time.
Often a structural change beats memoization. Move state down to the component that uses it, so its siblings stop re-rendering:
// Before: every keystroke re-renders ExpensiveTree
function PageBefore() {
const [text, setText] = useState("");
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ExpensiveTree />
</>
);
}
// After: the state lives in a component that doesn't contain ExpensiveTree
function SearchInput() {
const [text, setText] = useState("");
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
function Page() {
return <><SearchInput /><ExpensiveTree /></>;
}The related trick is passing expensive content as children to a wrapper that owns the state: the JSX is created by the wrapper’s parent, so it’s the same element object when the wrapper re-renders, and React can skip it.
Interview tip
When asked “would you wrap this in
useCallback?”, answer with the condition: only if it’s passed to amemocomponent or used as a hook dependency. Everywhere else it adds cost and no benefit.
Profile before you optimize
Guessing where time goes is unreliable, so measure first:
- React DevTools Profiler. Record an interaction and see which components rendered, how long each took, and (with the setting enabled) why each one rendered.
console.timearound a suspect calculation. The React docs suggest memoizing when it adds up to around 1ms or more.- Realistic conditions. Development builds are slower and Strict Mode renders twice, so confirm in a production build, ideally with CPU throttling.
React 19.2 also added Performance Tracks, which show React’s work inside the Chrome DevTools Performance panel.
The React Compiler
The React Compiler is a build-time tool that memoizes automatically. It analyzes your components and hooks and applies the equivalent of memo, useMemo and useCallback where it determines they help, so a component skips re-rendering when its inputs haven’t changed, without you writing any of it by hand. It’s stable and used in production.
It assumes your code follows the Rules of React: pure rendering, no mutation of props or state, hooks called unconditionally. When it finds code that might break those rules, it skips optimizing it rather than risk changing behavior. The React docs recommend relying on it for new code and keeping useMemo and useCallback as escape hatches when you need precise control, for example over a value used as an effect dependency. For existing code, leave current memoization in place or test carefully before removing it, since removing it can change the compiled output.
Note
The compiler doesn’t make this note obsolete. Knowing why components re-render and how referential equality works is what lets you read profiler output, debug code the compiler skipped, and answer the interview question.
The interview answer
“A component re-renders when its state changes, a context it reads changes, or its parent re-renders, and that last one happens whether or not its props changed. memo lets a component skip that when every prop is Object.is-equal to last time, which means objects and functions created during render break it, because they’re new references each time. useMemo and useCallback fix that by caching a value or a function until their dependencies change, and useMemo can also skip genuinely expensive calculations.
I don’t add them by default. I profile first, try structural fixes like moving state down or passing children, and memoize when there’s a measured cost. With the React Compiler, most of this is automatic, but I’d still use the hooks as escape hatches, and the mental model is the same.”