← Back to question bank
DebuggingSeniorHard#4018 · 35m

Debug controlled forms under interview pressure

A React screen using controlled forms 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 "mix controlled and uncontrolled inputs". Some fields use defaultValue and read from refs at submit time; others use value with onBlur instead of onChange. The race is invisible until the user types fast and submits before the blur fires — submission commits a value the user cannot see. The fix is fully-controlled inputs whose state is the single source of truth, with a small reducer that keeps change and submit handlers consistent.

Locate the boundary by asking "where does the submit handler read its values from?". If the answer is anything other than the same state the inputs are controlled by, you have two sources of truth and they will eventually disagree. The reducer makes the disagreement impossible because every change goes through the same path that produces the displayed value.

Adjacent traps: storing draft values outside React (refs, refs-of-refs, useEffect-synced state) "for performance" — the cost is correctness, not bytes. Submitting raw values without trimming or normalization (server bugs trace to client whitespace). And re-rendering the entire form on every keystroke when only one field changed (split into colocated subforms when this matters; React’s default render cost is rarely the actual problem).

Regression Fix: Fully-Controlled Inputs With Reducer

The fixed ContactForm makes the reducer state the single source of truth; both render and submit read from the same object.

// THE BUG: the original form mixed controlled and uncontrolled inputs:
// some used defaultValue and read DOM via refs, others used value but
// updated only on blur. Submission re-read fields, which produced a
// race where typing fast and clicking Submit committed a value the
// user could not see. The fix is fully-controlled inputs whose value
// is the single source of truth, with a small reducer to keep change
// and submit handlers consistent.

type FormState = {
  name: string;
  email: string;
  status: 'idle' | 'submitting' | 'error';
  error?: string;
};

type Action =
  | { type: 'change'; field: 'name' | 'email'; value: string }
  | { type: 'submit-start' }
  | { type: 'submit-error'; error: string }
  | { type: 'submit-success' };

function reducer(state: FormState, action: Action): FormState {
  if (action.type === 'change')
    return { ...state, status: 'idle', error: undefined, [action.field]: action.value };
  if (action.type === 'submit-start') return { ...state, status: 'submitting', error: undefined };
  if (action.type === 'submit-error') return { ...state, status: 'error', error: action.error };
  if (action.type === 'submit-success') return { ...state, status: 'idle', error: undefined };
  return state;
}

export function ContactForm({
  submit,
}: {
  submit: (values: { name: string; email: string }) => Promise<void>;
}) {
  const [state, dispatch] = React.useReducer(reducer, {
    name: '',
    email: '',
    status: 'idle',
  });

  return (
    <form
      onSubmit={async (event)=> {
        event.preventDefault();
        dispatch({ type: 'submit-start' });
        try {
          // Read state from React, never from the DOM. The render that
          // shows the spinner already committed the values being submitted.
          await submit({ name: state.name, email: state.email });
          dispatch({ type: 'submit-success' });
        } catch (error: unknown) {
          dispatch({ type: 'submit-error', error: (error as Error).message });
        }
      }}
    >
      <label>
        Name
        <input
          value={state.name}
          onChange={(event)=> dispatch({ type: 'change', field: 'name', value: event.target.value })}
        />
      </label>
      <label>
        Email
        <input
          value={state.email}
          onChange={(event)=> dispatch({ type: 'change', field: 'email', value: event.target.value })}
        />
      </label>
      <button type="submit" disabled={state.status= 'submitting'}>
        Submit
      </button>
      {state.error && <p role="alert">{state.error}</p>}
    </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 mixed form, type, fire submit immediately. The DOM-read submit may receive a stale value if onBlur did not fire. The fixed version always submits the value the user typed.
Patch
Replace defaultValue + ref reads with value + onChange. Move submit handler to read from React state. Test that submit receives the displayed values for the full keystroke sequence.
Prevent
Add a lint rule against defaultValue inside forms with controlled siblings. Pair with a Playwright smoke test that types and submits within one frame to catch the race the unit test cannot reproduce.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';

describe('ContactForm regression', () => {
  it('submits the value the user can see in the input', async () => {
    const submit = vi.fn(async () => {});
    render(<ContactForm submit={submit} />);
    const name = screen.getByLabelText('Name') as HTMLInputElement;
    fireEvent.change(name, { target: { value: 'Ada' } });
    fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'ada@example.com' } });
    // The broken version read DOM at submit time, so racing keystrokes
    // could change the value between display and submit. Here the
    // dispatched state is the source of truth for both render and submit.
    fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
    await waitFor(() =>
      expect(submit).toHaveBeenCalledWith({ name: 'Ada', email: 'ada@example.com' })
    );
  });

  it('disables the submit button while in flight', async () => {
    let resolveSubmit: () => void = () => {};
    const submit = vi.fn(() => new Promise<void>((resolve) => (resolveSubmit = resolve)));
    render(<ContactForm submit={submit} />);
    fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'A' } });
    fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
    expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled();
    resolveSubmit();
    await waitFor(() =>
      expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled()
    );
  });
});

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?