Debug transition-driven filtering under interview pressure
A React screen using transition-driven filtering 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 "expensive computation on every keystroke against the urgent value". The cost is felt immediately by the user — typing lags exactly as long as the filter takes. The fix is splitting the read sites: the input value stays urgent (typing always commits), the derived filter reads useDeferredValue (catches up under transition priority). The two states are kept in sync by React; you only choose where each is read.
Locate the boundary by asking "what is the user experiencing as latency?". Input lag → useDeferredValue on the read side. Heavy commit on the next route → useTransition on the write side. The two primitives compose: the input uses neither, the filter uses useDeferredValue, the route navigation uses useTransition. Picking the wrong site (deferring the input itself) breaks the contract.
Adjacent traps: forgetting to memoize the filter (the deferred value still triggers a recomputation if the closure churns), surfacing isStale without an aria-live region (screen readers see the lag without explanation), and assuming useDeferredValue debounces (it does not — it lags one render whenever React is busy, but every value commits eventually). The regression test asserts input.value updates synchronously after fireEvent.change.
Regression Fix: Defer The Filter, Keep The Input Urgent
The fixed FilteredList reads useDeferredValue on the filter side; the input stays controlled by the urgent state.
// THE BUG: an expensive filter re-ran on every keystroke against the
// urgent input value, blocking the input from rendering until the filter
// completed. The fix moves the filter behind useDeferredValue so the
// input is always responsive and the filtered list catches up under
// transition priority.
type Row = { id: string; label: string };
export function FilteredList({ rows }: { rows: Row[] }) {
const [query, setQuery] = React.useState('');
// Reading deferredQuery here means the filter never runs against the
// most recent keystroke — it always uses the value from one stable
// render ago. The input itself stays on urgent priority.
const deferredQuery = React.useDeferredValue(query);
const isStale = query !== deferredQuery;
const filtered = React.useMemo(
() =>
rows.filter((row) => row.label.toLowerCase().includes(deferredQuery.toLowerCase())),
[rows, deferredQuery]
);
return (
<div>
<input
aria-label="Filter"
value={query}
onChange={(event)=> setQuery(event.target.value)}
/>
{isStale && <span aria-live="polite">Updating…</span>}
<ul style={{ opacity: isStale ? 0.6 : 1 }}>
{filtered.map((row) => (
<li key={row.id}>{row.label}</li>
))}
</ul>
</div>
);
}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, fireEvent } from '@testing-library/react';
const rows = Array.from({ length: 30 }, (_, index) => ({
id: String(index),
label: 'item-' + index,
}));
describe('FilteredList regression', () => {
it('input value updates synchronously even while filter catches up', () => {
render(<FilteredList rows={rows} />);
const input = screen.getByLabelText('Filter') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'item-1' } });
// Input reflects the new value in the same commit; the filter
// catches up via useDeferredValue without blocking input render.
expect(input.value).toBe('item-1');
});
});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?