← Back to question bank
DebuggingSeniorHard#4020 · 35m

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.

Reproduce
Render the broken version, type three characters; assert server.search was called three times. The cost is a real production incident with rate limits or auth quota.
Patch
Introduce committed state and gate the effect on it. Test the submit path triggers exactly one fetch with the trimmed draft.
Prevent
Add a per-user request-rate budget metric and alert. Pair with an integration test that asserts a single submit produces a single network call to the search endpoint.
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?