Debug server state versus client state under interview pressure
A React screen using server state versus client state 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 "drive a server fetch from a controlled-input onChange". The cost is borne both by the server (request flood) and by the user (out-of-order results, flicker, dropped frames). The fix is a commit boundary: typing belongs to client state (draft); only an explicit user action (submit, debounced commit, button click) promotes draft to a server commit. Without this boundary, every keystroke is a server side-effect.
Locate the boundary by asking "what is user intent vs what is user thinking?". Drafting is thinking; submission is intent. Refactor the effect dependency to point at the committed value, never the draft. If the design genuinely requires typeahead, build it intentionally with debounce + cancellation + result merging — do not fall into it by accident.
Adjacent traps: storing the cache in component state (refetches on every remount), conflating loading with submitting (loading is "we are catching up to confirmed", submitting is "we are sending an intent"), and making the keystroke an effect dep but adding a debounce inside the effect (the debounce drops the abort signal — old work still races). The regression test asserts zero fetches across many keystrokes and exactly one fetch on submit.
Regression Fix: Draft / Committed Split With Submit Boundary
The fixed SearchPanel updates only client draft on keystroke; submit promotes draft to committed and the effect fires exactly one cancellable fetch.
// THE BUG: typing into the search input fired a fetch on every keystroke.
// At 60 wpm the server saw ~10 requests per second per user, and the
// list flickered between intermediate query results because resolutions
// arrived out of order. The fix splits draft (client) from committed
// (server). Typing updates draft only; submit promotes draft to
// committed and triggers exactly one fetch.
type Row = { id: string; title: string };
type Server = { search: (query: string, signal: AbortSignal) => Promise<Row[]> };
export function SearchPanel({ server }: { server: Server }) {
const [draft, setDraft] = React.useState('');
const [committed, setCommitted] = React.useState('');
const [rows, setRows] = React.useState<Row[]>([]);
const [status, setStatus] = React.useState<'idle' | 'loading' | 'error'>('idle');
React.useEffect(() => {
if (!committed) {
setRows([]);
return;
}
const controller = new AbortController();
setStatus('loading');
server
.search(committed, controller.signal)
.then((result) => {
setRows(result);
setStatus('idle');
})
.catch((error: Error) => {
if (controller.signal.aborted) return;
setStatus('error');
});
return () => controller.abort();
}, [committed, server]);
return (
<form
onSubmit={(event)=> {
event.preventDefault();
setCommitted(draft.trim());
}}
>
<input
aria-label="Search"
value={draft}
onChange={(event)=> setDraft(event.target.value)}
/>
<button type="submit">Search</button>
<ul>
{rows.map((row) => (
<li key={row.id}>{row.title}</li>
))}
</ul>
</form>
);
}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, fireEvent, waitFor } from '@testing-library/react';
describe('SearchPanel regression', () => {
it('typing does not trigger any fetches', () => {
const server = { search: vi.fn(async () => []) };
render(<SearchPanel server={server} />);
fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'q' } });
fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'qu' } });
fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'que' } });
expect(server.search).not.toHaveBeenCalled();
});
it('one submit triggers exactly one fetch', async () => {
const server = {
search: vi.fn(async () => [{ id: '1', title: 'Result' }]),
};
render(<SearchPanel server={server} />);
fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'q' } });
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(screen.getByText('Result')).toBeInTheDocument());
expect(server.search).toHaveBeenCalledTimes(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?