Debug useState derivation under interview pressure
A React screen using useState derivation 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 "mirror props into state with useEffect". It is the most common React production bug because it looks reasonable: keep a visibleTasks list in useState, then a useEffect with [tasks, filter] dependencies that calls setVisibleTasks. Two failure modes ship to users: the first render paints with the initial empty (or stale) list before the effect runs, and rapid prop changes race because effects are scheduled across commits. The fix is not "add another useEffect" — the fix is deleting the state.
Locate the boundary by asking "what is the source of truth?" If a value can be computed from props plus other state, the source of truth is whatever it depends on. Storing it again duplicates state and creates a sync window the user can see. Move the computation to render. If the cost is high, useMemo. If you reach for useEffect to copy values around, that is the smell.
Adjacent traps: setState inside useEffect with a guard like "only when prop differs" still loses the first render. Storing the result of a network call you already cache elsewhere causes inconsistency on revalidation. Computing inside a deep child via useEffect makes the bug invisible during code review. The regression test below paints once and asserts the list immediately — the old version failed it.
Regression Fix: Drop The Mirrored State, Derive At Render
The broken component synced visibleTasks via useEffect; this version derives the slice during render so the first commit is correct.
// THE BUG: the original component stored visibleTasks in useState and synced
// it from props inside useEffect. Two failure modes shipped to production:
// 1. The first render painted with stale [] before the effect ran.
// 2. Rapid prop changes raced; an older effect tick overwrote a newer
// filter result because the cleanup did not invalidate the in-flight
// derivation.
// The fix below removes the mirrored state entirely. The visible slice is
// derived at render. If profiling shows the filter is hot, useMemo it —
// never reintroduce useState for derivable values.
type Task = { id: string; title: string; tag: 'inbox' | 'today' | 'done' };
type Props = {
tasks: Task[];
filter: 'all' | 'today' | 'done';
};
export function TaskList({ tasks, filter }: Props) {
const visibleTasks =
filter === 'all' ? tasks : tasks.filter((task) => task.tag === filter);
return (
<ul aria-label="Tasks">
{visibleTasks.map((task) => (
<li key={task.id}>{task.title}</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';
const tasks: Task[] = [
{ id: 'a', title: 'Inbox A', tag: 'inbox' },
{ id: 'b', title: 'Today B', tag: 'today' },
];
describe('TaskList regression', () => {
it('paints the correct filter on the first commit (no effect lag)', () => {
render(<TaskList tasks={tasks} filter="today" />);
// The broken version returned an empty list on first render and then
// populated it after the syncing useEffect. This assertion would have
// failed with the previous implementation.
expect(screen.getByText('Today B')).toBeInTheDocument();
expect(screen.queryByText('Inbox A')).toBeNull();
});
it('reflects rapid filter changes without race', () => {
const { rerender } = render(<TaskList tasks={tasks} filter="today" />);
rerender(<TaskList tasks={tasks} filter="all" />);
rerender(<TaskList tasks={tasks} filter="today" />);
// Old effect-based version could leave stale "all" rows here if the
// effect from the middle render resolved last.
expect(screen.getAllByRole('listitem')).toHaveLength(1);
expect(screen.getByText('Today B')).toBeInTheDocument();
});
});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?