A parent renders `<Row style={{ padding: 8 }} tags={[]} onSelect={() => onPick(row.id)} />` and `Row` is wrapped in `React.memo`, yet Row re-renders every time the parent renders. Why does React.memo not skip it, and how do you make it skip?
answer
- identical in shape, new in identity
- one prop at a time, no depth
- literals evaluated during the parent render
- fix at the creation site, not the child
basics
~20 sReact.memo re-renders whenever a prop differs by Object.is. The inline object, array and arrow are created fresh during each parent render, so all three are new references every time and the shallow comparison always reports a change.
solid answer
~50 s`React.memo` wraps the component in a shallow prop comparison: for each prop key it runs `Object.is(prev, next)`, and it bails out of rendering only when every prop passes. Object literals, array literals and arrow functions are evaluated while the parent renders, so `{ padding: 8 }`, `[]` and the arrow are brand-new values on every render even though their contents never change — `Object.is` reports a difference and memo renders anyway. The fix belongs at the creation site, not inside Row: hoist genuinely invariant values like the empty array and the style object to module scope, wrap derived objects in `useMemo`, and wrap the handler in `useCallback` — or pass `id` plus one stable `onPick` so no per-row closure is needed. Memoization is only as strong as the least stable prop: one unstable prop defeats the whole comparison.
code
javascript · 33 linesimport { memo, useCallback, useState } from 'react';
const EMPTY_TAGS = [];
const ROW_STYLE = { padding: 8 };
const Row = memo(function Row({ id, style, tags, onPick }) {
// this arrow is created during Row's own render and is not a prop,
// so it costs the memo comparison nothing
return (
<li style={style} onClick={() => onPick(id)}>
{id} ({tags.length})
</li>
);
});
export function List({ rows }) {
const [picked, setPicked] = useState(null);
const handlePick = useCallback((id) => setPicked(id), []);
return (
<ul data-picked={picked}>
{rows.map((row) => (
<Row
key={row.id}
id={row.id}
style={ROW_STYLE}
tags={EMPTY_TAGS}
onPick={handlePick}
/>
))}
</ul>
);
}go deeper
Know that a component body runs again on every render, so any object, array or arrow written inside it is a brand-new value each time. Say plainly that React.memo compares references, not contents.
Be ready to walk the mechanics: memo does one Object.is per prop key, bails out only if all pass, and inline literals guarantee a failure. Name the three fixes — module constant, useMemo, useCallback — and say which prop each suits.
Show that you fix instability at the creation site and audit every prop crossing the boundary, since one unstable prop defeats the whole comparison. Mention the mirror trap: in-place mutation makes memo bail out on data that really changed.
Own the policy question — where memo boundaries belong at all, whether flattening props to primitives or restructuring the tree removes the need, and how a team keeps stabilization from spreading into cargo-cult useMemo on every line.
## The distinction candidates trip over Two objects can be *equal in content* and still be *different values*. Writing `{ padding: 8 }` twice allocates two independent objects, and an identity comparison reports them as different. React never walks your props looking for content equality — it compares references. That one fact explains nearly every "my memoization does nothing" bug. ## What React.memo actually compares `React.memo(Component)` returns a wrapper that, when the parent re-renders, takes the previous props object and the next one and compares them **shallowly**: the same set of keys, and for every key `Object.is(prev[key], next[key])`. `Object.is` is the same sameness check React uses when deciding whether a `useState` update can bail out; for objects and functions it is plain reference identity (it differs from `===` only for `NaN` and `+0`/`-0`). If every prop passes, React skips rendering that component and reuses the previous output for its subtree. If a single prop fails, the component renders normally. There is no deep comparison and no "looks the same" heuristic. ## Why the inline props always fail A function component's body is ordinary code that runs top to bottom on every render, and evaluating JSX evaluates the expressions inside the attributes: ```jsx <Row style={{ padding: 8 }} tags={[]} onSelect={() => onPick(row.id)} /> ``` Each parent render allocates a fresh object for `{ padding: 8 }`, a fresh array for `[]`, and a fresh function object for the arrow. Their contents are identical, their identities are not, so `Object.is` returns `false` three times over and `Row` re-renders. In code review the component *looks* memoized; it simply never bails out. ## Fixing it where the prop is created - **Invariant values go to module scope.** `const EMPTY_TAGS = [];` and `const ROW_STYLE = { padding: 8 };` above the component are allocated once for the module's lifetime, so every render passes the identical reference. This is the cheapest fix and needs no hook at all. - **Derived values go in `useMemo`.** `const visible = useMemo(() => rows.filter(Boolean), [rows]);` keeps one array until `rows` itself changes. - **Handlers go in `useCallback` — or disappear.** `useCallback(fn, deps)` returns the same function object until a dependency changes. Often better: pass `id` and one stable `onPick`, and let the child write `onClick={() => onPick(id)}`. That arrow is created during the *child's* own render and is not a prop, so it costs the memo comparison nothing. Note the direction of the fix. You cannot repair this from inside `Row`. Memoization pays only when the props arriving at the boundary are already stable, which means the parent owns the problem. ## The weakest-link rule The comparison is an AND across all props. A team that carefully memoizes four props and leaves a fifth inline gets exactly the behaviour of memoizing nothing — plus the cost of the extra comparison and the retained hook caches. When you memoize a boundary, audit *every* prop crossing it, including the ones added later by someone else. ```jsx // still re-renders every time: one unstable prop is enough <Row style={ROW_STYLE} tags={EMPTY_TAGS} meta={{ level: 1 }} /> ``` ## The mirror-image trap: mutation The same shallow rule bites in the opposite direction. If you mutate an object in place — `filters.status = 'open'` — and pass the *same* object down, `Object.is` reports no change, memo bails out, and the child renders stale data. Shallow identity comparison assumes you treat state as immutable: replace objects (`{ ...filters, status: 'open' }`) instead of editing them, so a real change is visible as a new reference. ## Reading it from the outside Before reaching for hooks, ask whether the prop needs to be an object at all. `padding={8}` is a primitive and compares by value; `style={{ padding: 8 }}` is an object and compares by identity. Flattening a prop to primitives, or hoisting a constant, removes the problem instead of managing it — and it survives a future refactor better than a `useMemo` whose dependency list someone edits. Finally, remember that every stabilization has a price: extra hook calls, retained caches, and dependency arrays to keep honest. Stabilize identity where a memo boundary or an effect actually depends on it, not reflexively everywhere.
- Does React.memo compare props deeply, and what happens if the parent mutates a prop object in place instead of replacing it?The default comparison is shallow — one `Object.is` per prop key, no recursion. Mutating in place keeps the same reference, so memo sees no change, bails out, and the child renders stale data. That is why React expects immutable updates: replace the object with a spread copy so a real change shows up as a new reference.
- The parent wraps every prop in useMemo and useCallback, and the memoized child still re-renders. What do you check next?Check the dependency arrays of those hooks: if a dependency is itself recreated each render, the memoized result is recreated too and the instability just moved one level up. Also verify the memo actually applies — a component recreated inside another component's body, or `memo()` called on a fresh function each render, produces a new type and re-mounts rather than bails out.
- Why is passing `id` plus a shared `onPick` usually better than `useCallback` per row?A per-row `useCallback` still allocates one hook cache and one dependency array per row, and it changes whenever `id` changes. Passing the primitive `id` and a single handler stable for the list's lifetime gives every row the same function reference, so the memo comparison is trivially true and the list scales without per-item bookkeeping.
React compares serial numbers, not shapes: two keys cut from the same blank open the same lock, but a clerk checking serial numbers still records them as two different keys.
saying these in an interview costs you the question
- Claiming React.memo deep-compares props
- Saying two objects with the same contents are equal
- Believing wrapping the child alone is enough
- Thinking useCallback makes the function run faster
- Assuming React caches JSX attribute literals between renders