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.
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?