Implement transition-driven filtering in a product component
Use transition-driven filtering to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
Transition-driven filtering is the question that asks whether you can keep input responsive while a heavy list re-renders. The default behavior catches engineers: typing into a controlled input that drives an expensive filter blocks every keystroke on the filter cost. The fix is splitting two state systems — the input value (urgent) and the derived filtered list (transition). React 18 gives two primitives for this: useDeferredValue for read-side derivation and useTransition for write-side commits.
Pick the primitive by where the cost lives. If you read the value to compute something expensive (filter, sort, format), useDeferredValue is the cleaner answer because the computation lags a render but the input is unaffected. If you commit to a different state that triggers expensive children, wrap the setState in startTransition and surface isPending so the UI shows it is catching up. The reference uses both because the question is broad enough to merit it.
Adjacent traps: putting useDeferredValue on the input itself (the input becomes laggy and feels broken), forgetting to memoize the filter (defeats the deferred update because the comparison still happens every render), and using startTransition without surfacing isPending (the user has no signal that intermediate states are stale). The test asserts the input updates synchronously while the filter catches up — the canonical responsive-input contract.
Reference Implementation: Deferred Filter With Transition Commit
A FilteredList that derives results from useDeferredValue and exposes isPending so the UI shows it is catching up after each keystroke.
// useTransition keeps the input responsive when downstream rendering is
// expensive. The senior detail: input value is urgent state, the derived
// filtered list is transition state. Both must be present and synchronized,
// or the input lags exactly the same as the list.
type Row = { id: string; label: string };
type Props = {
rows: Row[];
};
export function FilteredList({ rows }: Props) {
const [query, setQuery] = React.useState('');
// deferredQuery lags one render behind query when work is pending; the
// input always shows the latest keystroke and the list catches up.
const deferredQuery = React.useDeferredValue(query);
const [isPending, startTransition] = React.useTransition();
const [committedQuery, setCommittedQuery] = React.useState('');
// The filter happens against deferredQuery, not query. That is the entire
// performance win — typing does not block on the filter pass.
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)=> {
const next= event.target.value;
setQuery(next);
// Wrap the second commit in startTransition so React schedules
// it as low priority. If the user types again, this commit is
// discarded before it paints.
startTransition(()=> setCommittedQuery(next));
}}
/>
{isPending && <span aria-live="polite">Updating...</span>}
<p data-testid="committed">{committedQuery}</p>
<ul aria-label="Results">
{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: 50 }, (_, index) => ({
id: String(index),
label: 'item-' + index,
}));
describe('FilteredList', () => {
it('input value updates urgently while filter catches up', () => {
render(<FilteredList rows={rows} />);
const input = screen.getByLabelText('Filter');
fireEvent.change(input, { target: { value: 'item-1' } });
// Input reflects keystroke immediately.
expect(input).toHaveValue('item-1');
// Filtered list shows the matching subset because deferredValue catches
// up within the same act-flushed batch in tests.
expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0);
});
it('exposes pending state for the user', () => {
render(<FilteredList rows={rows} />);
fireEvent.change(screen.getByLabelText('Filter'), { target: { value: 'zzz' } });
// The empty result still renders cleanly without an indicator hang.
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
expect(screen.getByTestId('committed')).toHaveTextContent('zzz');
});
});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?