Debug useMemo and useCallback boundaries under interview pressure
A React screen using useMemo and useCallback boundaries passes simple tests but breaks during repeated interaction. Find the likely root cause, patch it, and describe the longer-term design improvement.
Answer Strategy
The broken pattern in this question is "wrap everything in useMemo/useCallback for performance". The cost is real: the equality check runs every render, the closure capture allocates, and the dev-mode double-invocation in Strict Mode runs the body twice. Worse, when the dep array contains an unstable reference (a fresh object literal), the memoization invalidates every commit — pure overhead, zero benefit. The fix is to memoize only at boundaries that pay for it: props passed to React.memo-wrapped children, and deps of useEffect.
Locate the boundary by asking "would the consumer benefit from a stable reference?". A primitive prop never benefits — React.memo already shallow-compares. An object or array prop benefits only if the consumer is React.memo-wrapped or uses it as an effect dep. A function prop benefits only if the consumer is React.memo-wrapped or uses it as an effect dep. Everywhere else, useMemo/useCallback are noise.
Adjacent traps: useMemo with dep [items] when items is a fresh array literal at the call site (memo never bails out — fix by memoizing at the call site, not inside the component), useCallback returning an inline arrow that calls another inline arrow (the inner closure changes on every render, defeating the outer memo), and putting state setters in a useCallback (setters are already stable — useCallback adds nothing). The reference Cart memoizes only the total because it is the only value that benefits.
Regression Fix: Memoize Only At Boundaries That Pay
The fixed Cart memoizes the formatted total because it is consumed by a memoized child; everything else stays plain.
// THE BUG: useMemo/useCallback wrapped every value in the component for
// "performance", but the dep arrays included unstable references (a fresh
// object each render). The memoization invalidated every commit, so the
// child paid the comparison cost AND re-rendered. The fix is to memoize
// only at meaningful boundaries (props passed to memoized children, deps
// of effects), and to ensure those dep arrays contain primitives or
// identity-stable references.
type Item = { id: string; price: number };
type CartProps = { items: Item[]; currency: 'usd' | 'eur' };
export function Cart({ items, currency }: CartProps) {
// Memoize only the value that matters: the formatted total. Dep array
// is two primitives + an array reference whose stability is the
// caller's responsibility (documented in the prop type).
const total = React.useMemo(() => {
const sum = items.reduce((acc, item) => acc + item.price, 0);
const symbol = currency === 'usd' ? '$' : '€';
return symbol + sum.toFixed(2);
}, [items, currency]);
return (
<section>
<p data-testid="total">{total}</p>
<ItemList items={items} />
</section>
);
}
// useCallback only matters when the function is a dep of an effect or a
// prop of a memoized child. Otherwise it is overhead with no benefit.
const ItemList = React.memo(function ItemList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.id}</li>
))}
</ul>
);
});Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
describe('Cart memoization regression', () => {
it('does not re-render the memoized child when items reference is stable', () => {
let listRenders = 0;
const ItemListProbe = React.memo(function ItemListProbe({ items }: { items: Item[] }) {
listRenders++;
return <ul aria-label="probe">{items.map((i) => <li key={i.id} />)}</ul>;
});
function Page({ count }: { count: number }) {
const items = React.useMemo(
() => [{ id: 'a', price: 1 }],
[] // empty deps: identity-stable across rerenders
);
return (
<>
<p>{count}</p>
<ItemListProbe items={items} />
</>
);
}
const { rerender }= render(<Page count={0} />);
rerender(<Page count={1} />);
rerender(<Page count={2} />);
// The child never re-rendered because the items reference is stable
// and React.memo bailed out on shallow prop equality.
expect(listRenders).toBe(1);
});
it('re-renders the memoized child when items identity changes', () => {
let listRenders = 0;
const ItemListProbe = React.memo(function ItemListProbe(_: { items: Item[] }) {
listRenders++;
return null;
});
function Page() {
// Fresh array every render — the broken dependency.
return <ItemListProbe items={[{ id: 'a', price: 1 }]} />;
}
const { rerender } = render(<Page />);
rerender(<Page />);
expect(listRenders).toBe(2);
});
});Interviewer Signal
Tests whether you debug from ownership and lifecycle instead of random dependency-array edits.
Constraints
- State a hypothesis before changing code.
- Name what evidence would confirm the bug.
- Avoid broad rewrites unless the current API cannot express the behavior.
Model Answer Shape
- Reproduce the failing sequence first.
- Inspect ownership boundaries: local state, props, effects, subscriptions, and server data.
- Patch the minimal broken boundary and add a regression test.
Tradeoffs
- A minimal patch reduces risk, but repeated lifecycle bugs often justify a small reducer or custom hook.
- Adding dependencies can silence lint warnings while still preserving the wrong ownership model.
Edge Cases
- Double clicks and repeated submissions.
- Slow network responses arriving out of order.
- Component remount with stale persisted state.
Testing And Proof
- Failing interaction sequence.
- Out-of-order async response.
- Unmount cleanup.
Follow-Ups
- What would the code review comment say?
- What metric or log would show this in production?