Implement useMemo and useCallback boundaries in a product component
Use useMemo and useCallback boundaries to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
useMemo and useCallback are the question that exposes whether you measure before optimizing. The interview win is naming when memoization buys you something: when expensive compute would otherwise re-run every render, OR when downstream React.memo (or context selectors) needs a reference-stable prop. Memoizing for "speed" without measuring usually only adds a Map lookup and an allocation per render.
Separate three layers. Pure functions live outside the component (applyFilters as the example) so they can be tested without React. useMemo wraps a derivation that should not allocate a new reference unless its inputs changed. useCallback wraps a function that flows into a memoized child so React.memo can short-circuit. Each layer has a different reason to exist.
Volunteer the failures. Memoizing every callback creates a graveyard of stale closures: a useCallback with the wrong dependency array silently captures yesterday's state. Memoizing a sort against an array that is recreated upstream every render is wasted work. A React.memo on a leaf that already reconciles cheaply is pure overhead. The senior answer names the diagnostic (React DevTools profiler, why-did-you-render, marker spans) before reaching for a hook.
Reference Implementation: Memoization Boundaries In A Product Dashboard
A dashboard whose pure filter is unit-testable, whose memoized derivation flows into a React.memo child, and whose callback identity is preserved across parent renders.
type Row = { id: string; team: string; status: 'open' | 'closed'; ageDays: number };
type FilterState = { team: string | null; status: 'all' | 'open' | 'closed' };
// Pure derivation lifted out of the component so it can be unit tested
// without rendering React. Memoization in the component re-uses this exact
// function, so identity stability is a function of the *inputs*, not the
// component lifecycle.
function applyFilters(rows: Row[], filters: FilterState): Row[] {
return rows.filter((row) => {
if (filters.team && row.team !== filters.team) return false;
if (filters.status === 'open' && row.status !== 'open') return false;
if (filters.status === 'closed' && row.status !== 'closed') return false;
return true;
});
}
type DashboardProps = {
rows: Row[];
filters: FilterState;
onSelect: (id: string) => void;
};
function RowItemBase({ row, onSelect }: { row: Row; onSelect: (id: string) => void }) {
return (
<li>
<button type="button" onClick={()=> onSelect(row.id)}>
{row.team} / {row.id} - {row.status}
</button>
</li>
);
}
// React.memo only helps if the props it receives are reference-stable. The
// onSelect prop must therefore come from a useCallback in the parent, not
// from an inline function created on every render.
const RowItem = React.memo(RowItemBase);
export function Dashboard({ rows, filters, onSelect }: DashboardProps) {
// Cheap filter (~ms) so memoization is mostly about *identity* stability
// for downstream React.memo, not raw compute time. Measure before adding
// useMemo for performance reasons; default to no memo when in doubt.
const filtered = React.useMemo(() => applyFilters(rows, filters), [rows, filters]);
// The selection summary recomputes only when the filtered list changes.
// This is a defensible useMemo: filtered is a new array each render, but
// the summary is computed from it.
const summary = React.useMemo(
() => ({
total: filtered.length,
openCount: filtered.filter((row) => row.status === 'open').length,
}),
[filtered]
);
// Wrap callbacks that flow into memoized children so React.memo can short-
// circuit. Without this, every parent render passes a new function and
// every RowItem re-renders.
const handleSelect = React.useCallback(
(id: string) => onSelect(id),
[onSelect]
);
return (
<section>
<header>
<p>{summary.total} matching ({summary.openCount} open)</p>
</header>
<ul>
{filtered.map((row) => (
<RowItem key={row.id} row={row} onSelect={handleSelect} />
))}
</ul>
</section>
);
}Runnable Playground
Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.
type Row = { id: string; team: string; status: 'open' | 'closed'; ageDays: number };
type FilterState = { team: string | null; status: 'all' | 'open' | 'closed' };
function applyFilters(rows: Row[], filters: FilterState): Row[] {
return rows.filter((row) => {
if (filters.team && row.team !== filters.team) return false;
if (filters.status === 'open' && row.status !== 'open') return false;
if (filters.status === 'closed' && row.status !== 'closed') return false;
return true;
});
}
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, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('Dashboard', () => {
const baseRows = [
{ id: 'r1', team: 'core', status: 'open' as const, ageDays: 1 },
{ id: 'r2', team: 'growth', status: 'closed' as const, ageDays: 5 },
{ id: 'r3', team: 'core', status: 'open' as const, ageDays: 2 },
];
it('filters rows by team and status', () => {
render(
<Dashboard
rows={baseRows}
filters={{ team: 'core', status: 'open' }}
onSelect={()=> {}}
/>
);
expect(screen.getAllByRole('button')).toHaveLength(2);
});
it('keeps RowItem stable when unrelated parent state changes', () => {
const renderSpy = vi.fn();
function Spy({ row, onSelect }: any) {
renderSpy(row.id);
return <button onClick={()=> onSelect(row.id)}>{row.id}</button>;
}
const Memoized = React.memo(Spy);
function Parent() {
const [_, setTick] = React.useState(0);
const onSelect = React.useCallback(() => {}, []);
return (
<>
<button onClick={()=> setTick((t)=> t + 1)}>tick</button>
<Memoized row={baseRows[0]} onSelect={onSelect} />
</>
);
}
render(<Parent />);
renderSpy.mockClear();
fireEvent.click(screen.getByText('tick'));
expect(renderSpy).not.toHaveBeenCalled();
});
});Interviewer Signal
Shows whether you can explain React behavior while building maintainable product UI.
Constraints
- Name what is render-derived and what is stored state.
- Keep side effects owned by events or effects deliberately.
- Provide a testable boundary for business logic.
Model Answer Shape
- Start from the user workflow and state ownership.
- Move pure decisions out of JSX when the branch logic grows.
- Use React primitives to express ownership, not to hide unclear state.
Tradeoffs
- Colocating state improves clarity until sibling coordination becomes the real problem.
- Memoization helps only after render cost or identity churn is measured.
Edge Cases
- Strict Mode re-running development effects.
- Stale closures after async work resolves.
- Unmounts and route changes during in-flight operations.
Testing And Proof
- Reducer or pure function test for core state transitions.
- Interaction test for the user workflow.
- Regression case for stale or repeated async behavior.
Follow-Ups
- How would this change with server rendering?
- Where would you place this state in a larger app?