“I wrapped the row in React.memo and it still re-renders on every keystroke. Why?” This is a favourite interview question because the fix is rarely in the memoised component. It is in the parent, which creates a new object or function on every render and hands it down as a prop.
The interviewer wants to hear that memo is a shallow, reference-based comparison, and that memoising a child is only half of a contract the parent must also keep.
Before you start
You should know how props flow from parent to child and that a parent re-render re-renders its children by default. Understand the difference between comparing values and comparing references in JavaScript. Familiarity with useMemo and useCallback helps. Everything here holds in React 18 and 19; if the React Compiler is enabled, it inserts much of this memoisation for you, which is useful to mention but not a substitute for understanding it.
The short answer
memo(Component) makes React skip re-rendering that component when its parent re-renders, as long as every prop is equal to the previous prop by Object.is. Primitives compare by value, but objects, arrays, functions and JSX elements compare by reference, and writing them inline creates a new reference each render. So a memoised child receiving style={{...}} or onClick={() => ...} re-renders every time. Fix it by stabilising those props in the parent, by passing primitives, or by restructuring so the expensive child is not under the changing state at all.
How it works
The default comparison is shallow:
function shallowEqual(prev, next) {
const keys = Object.keys(next);
if (keys.length !== Object.keys(prev).length) return false;
return keys.every(k => Object.is(prev[k], next[k]));
}
shallowEqual({ id: 1 }, { id: 1 }); // true
shallowEqual({ style: { color: 'red' } },
{ style: { color: 'red' } }); // false
const fn = () => {};
shallowEqual({ onClick: fn }, { onClick: fn }); // true
shallowEqual({ onClick: () => {} }, { onClick: () => {} }); // falseNow look at a typical parent:
const Chart = memo(function Chart({ data, options, onSelect }) {
// expensive SVG rendering
});
function Dashboard({ rows }) {
const [filter, setFilter] = useState('');
return (
<>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<Chart
data={rows.filter(r => r.active)}
options={{ legend: true }}
onSelect={id => console.log(id)}
/>
</>
);
}Typing in the filter re-renders Dashboard. All three props of Chart are fresh references, so memo compares, finds differences, and renders anyway. You now pay for the comparison and the render.
Step-by-step walkthrough
Step 1: Confirm the re-render and its cause
Open React DevTools, enable “Highlight updates when components render”, or record in the Profiler with “Record why each component rendered” turned on. The Profiler will say something like “Props changed: options, onSelect”. That tells you which props to stabilise.
Step 2: Hoist constants out of the component
An object that never depends on props or state should not be created during render:
const CHART_OPTIONS = { legend: true };
// ...
<Chart options={CHART_OPTIONS} />This has zero runtime cost and no dependency list.
Step 3: Memoise derived values and callbacks
Values that depend on props need useMemo; functions need useCallback:
const activeRows = useMemo(() => rows.filter(r => r.active), [rows]);
const handleSelect = useCallback(id => setSelectedId(id), []);
<Chart data={activeRows} options={CHART_OPTIONS} onSelect={handleSelect} />Now typing in the filter changes none of Chart’s props, and React skips it. Note that rows itself must be stable from its parent, or the chain breaks one level up.
Step 4: Prefer structure over memoisation where possible
Often the cleanest fix is moving the fast-changing state down so it does not re-render the expensive sibling:
function FilterBox() {
const [filter, setFilter] = useState('');
return <input value={filter} onChange={e => setFilter(e.target.value)} />;
}
function Dashboard({ rows }) {
return (
<>
<FilterBox />
<Chart data={rows} />
</>
);
}Dashboard no longer re-renders on keystrokes, so Chart does not need memo for this case at all. Passing expensive subtrees as children from a stable owner achieves the same effect.
Worked scenario
A data table with 500 memoised rows lags while the user types in a search box above it:
{rows.map(row => (
<Row
key={row.id}
row={row}
columns={columns.filter(c => c.visible)}
onToggle={() => toggle(row.id)}
/>
))}The Profiler shows every Row rendering with “Props changed: columns, onToggle”. Two references break memoisation: the filtered columns array is rebuilt per row per render, and each row gets a new arrow function.
The fix:
const visibleColumns = useMemo(() => columns.filter(c => c.visible), [columns]);
const onToggle = useCallback(id => {
setSelected(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
{rows.map(row => (
<Row key={row.id} row={row} columns={visibleColumns} onToggle={onToggle} />
))}Row now calls onToggle(row.id) itself. Typing re-renders only the table container; all 500 rows are skipped. If selection is shown per row, pass a boolean selected={selected.has(row.id)} so only the changed rows re-render.
Common mistake
- Memoising the child and forgetting the parent.
memowithout stable props is pure overhead. - Passing
childrento a memoised component. JSX elements are new objects each render, so<Card>{content}</Card>defeatsmemo(Card)unless the children are themselves memoised. - Expecting
memoto block context or local state. A memoised component still re-renders when its own state changes or a context it reads changes. See context scope. - Writing a deep custom comparator. A deep compare on large props can be slower than re-rendering, and a comparator that ignores function props can cause stale callbacks.
- Memoising everything by default. Most components are cheap; measure first.
Verify the behavior
Add a render log and compare before and after stabilising:
const Child = memo(function Child({ config }) {
console.log('Child rendered');
return <p>{config.label}</p>;
});
const CONFIG = { label: 'stable' };
export default function Parent() {
const [n, setN] = useState(0);
return (
<>
<button onClick={() => setN(n + 1)}>{n}</button>
<Child config={{ label: 'inline' }} />
<Child config={CONFIG} />
</>
);
}After the initial mount, each click logs Child rendered exactly once: the inline-config child renders, the hoisted-config child is skipped. In the Profiler, the skipped child appears greyed out with “Did not render”.
Follow-up questions
What is the second argument to memo? An optional arePropsEqual(prev, next) that returns true to skip. Use it rarely, and never ignore function props unless you are sure they cannot be stale.
How does useMemo differ from memo? useMemo caches a value inside a component; memo caches a component’s render output based on props.
Does memo prevent the first render? No. It only skips re-renders when props are unchanged.
What does the React Compiler change? It automatically memoises components, JSX and values based on their inputs, so many manual memo, useMemo and useCallback calls become unnecessary in compiled code. See memo, useMemo and useCallback.
Interview exercise
<Avatar user={user} size={48} /> is wrapped in memo, yet it re-renders every time the parent’s unrelated clock ticks. The parent computes const user = { name: profile.name, url: profile.photo }; in its body. Give two fixes and say which you prefer.
Answer and reasoning
The parent creates a new user object each render, so the shallow comparison fails even though the fields are identical. Fix one: const user = useMemo(() => ({ name: profile.name, url: profile.photo }), [profile.name, profile.photo]);. Fix two: change the API to primitives, <Avatar name={profile.name} url={profile.photo} size={48} />, which needs no hook and compares by value. I prefer the second because it removes the reference problem entirely and makes the component’s dependencies explicit. A third option is to move the clock into its own component so the parent does not re-render each tick.
Continue learning
Continue with the React interview questions and the React MCQs. Related notes: useCallback and captured values, memo, useMemo and useCallback and context splitting for performance. Official reference: the memo reference on react.dev.