Implement controlled forms in a product component
Use controlled forms to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
Controlled forms is the question that exposes whether you can name three sources of state — values, validity, submission status — and keep them separate. The interview anti-pattern is one giant useState({ values, errors, isSubmitting }), which is why a reducer is the right primitive: each transition is a named action that you can test pure.
Pure validation lives outside the component as a function from values to a record of errors. The submit handler dispatches submit-start, awaits the parent's onSubmit, then dispatches success or error. The button is disabled when there are validation errors OR a submission is in flight. Each piece can be tested without rendering anything.
Volunteer the failure modes: uncontrolled inputs that bypass React state and silently break in test environments; submit handlers that re-fire on double click; aria-invalid and aria-describedby missing so screen readers cannot connect errors to the right field; reset behavior that does not return focus to the first invalid input; submit success transitions that do not clear stale error banners. The reference shows the contract: noValidate on the form, controlled values, dispatched transitions, accessible error messaging.
Reference Implementation: Controlled Form With Reducer And Pure Validation
InviteForm uses a typed reducer for state transitions and a pure validate function for per-field errors. The parent owns the side effect of submission.
type FormState = {
values: { name: string; email: string; role: 'viewer' | 'editor' | 'admin' };
// Submission status, separate from per-field validity, so the button can
// show "Saving..." even when fields are still being edited.
status: 'idle' | 'submitting' | 'error';
errorMessage?: string;
};
type FormAction =
| { type: 'change'; field: keyof FormState['values']; value: string }
| { type: 'submit-start' }
| { type: 'submit-error'; message: string }
| { type: 'submit-success' };
export function formReducer(state: FormState, action: FormAction): FormState {
if (action.type === 'change') {
return {
...state,
// status returns to idle on edit so error banners auto-clear.
status: state.status === 'error' ? 'idle' : state.status,
errorMessage: undefined,
values: { ...state.values, [action.field]: action.value as never },
};
}
if (action.type === 'submit-start') return { ...state, status: 'submitting', errorMessage: undefined };
if (action.type === 'submit-error') return { ...state, status: 'error', errorMessage: action.message };
if (action.type === 'submit-success') return { ...state, status: 'idle', errorMessage: undefined };
return state;
}
// Pure validation kept outside the reducer so it can be unit-tested cleanly
// and reused on the server. Returns a record so multiple errors can render
// next to their fields rather than as a single global banner.
function validate(values: FormState['values']) {
const errors: Partial<Record<keyof FormState['values'], string>> = {};
if (values.name.trim().length < 2) errors.name= 'Name must be at least two characters.';
if (!/.+@.+\..+/.test(values.email)) errors.email= 'Enter a valid email address.';
return errors;
}
const initial: FormState= {
values: { name: '', email: '', role: 'viewer' },
status: 'idle',
};
type InviteFormProps= {
onSubmit: (values: FormState['values'])=> Promise<void>;
};
export function InviteForm({ onSubmit }: InviteFormProps) {
const [state, dispatch] = React.useReducer(formReducer, initial);
const errors = validate(state.values);
const hasErrors = Object.keys(errors).length > 0;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (hasErrors) return;
dispatch({ type: 'submit-start' });
try {
await onSubmit(state.values);
dispatch({ type: 'submit-success' });
} catch (error) {
dispatch({
type: 'submit-error',
message: error instanceof Error ? error.message : 'Submit failed',
});
}
}
return (
<form noValidate onSubmit={handleSubmit}>
<Field label="Name" error={errors.name}>
<input
name="name"
value={state.values.name}
// Errors reference the field via aria-describedby so screen readers
// hear the validation message when the input is focused.
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? 'name-error' : undefined}
onChange={(event)=>
dispatch({ type: 'change', field: 'name', value: event.target.value })
}
/>
</Field>
<Field label="Email" error={errors.email}>
<input
name="email"
type="email"
value={state.values.email}
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'email-error' : undefined}
onChange={(event)=>
dispatch({ type: 'change', field: 'email', value: event.target.value })
}
/>
</Field>
<label>
Role
<select
value={state.values.role}
onChange={(event)=>
dispatch({ type: 'change', field: 'role', value: event.target.value })
}
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</label>
{state.status === 'error' && (
<p role="alert">{state.errorMessage}</p>
)}
<button type="submit" disabled={state.status= 'submitting' || hasErrors}>
{state.status === 'submitting' ? 'Saving...' : 'Send invite'}
</button>
</form>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactElement;
}) {
return (
<label className="field">
<span>{label}</span>
{children}
{error && (
<span id={(children.props as { name: string }).name + '-error'} role="status">
{error}
</span>
)}
</label>
);
}Runnable Playground
Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.
type FormValues = { name: string; email: string; role: 'viewer' | 'editor' | 'admin' };
function validate(values: FormValues) {
const errors: Partial<Record<keyof FormValues, string>> = {};
if (values.name.trim().length < 2) errors.name = 'Name must be at least two characters.';
if (!/.+@.+\..+/.test(values.email)) errors.email = 'Enter a valid email address.';
return errors;
}
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 } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('formReducer', () => {
const initial = { values: { name: '', email: '', role: 'viewer' as const }, status: 'idle' as const };
it('change action updates the field and clears error status', () => {
const next = formReducer(
{ ...initial, status: 'error', errorMessage: 'oops' },
{ type: 'change', field: 'name', value: 'Ada' }
);
expect(next.values.name).toBe('Ada');
expect(next.status).toBe('idle');
expect(next.errorMessage).toBeUndefined();
});
});
describe('InviteForm', () => {
it('blocks submit when fields are invalid', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<InviteForm onSubmit={onSubmit} />);
await user.click(screen.getByRole('button', { name: /send invite/i }));
expect(onSubmit).not.toHaveBeenCalled();
});
it('shows error banner when submit rejects', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockRejectedValue(new Error('Network down'));
render(<InviteForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'Ada Lovelace');
await user.type(screen.getByLabelText(/email/i), 'ada@example.com');
await user.click(screen.getByRole('button', { name: /send invite/i }));
expect(await screen.findByRole('alert')).toHaveTextContent('Network down');
});
});Interviewer Signal
Shows whether you can explain React behavior while building maintainable product UI.
Constraints
- Name what is render-derived and what is stored state.
- Keep side effects owned by events or effects deliberately.
- Provide a testable boundary for business logic.
Model Answer Shape
- Start from the user workflow and state ownership.
- Move pure decisions out of JSX when the branch logic grows.
- Use React primitives to express ownership, not to hide unclear state.
Tradeoffs
- Colocating state improves clarity until sibling coordination becomes the real problem.
- Memoization helps only after render cost or identity churn is measured.
Edge Cases
- Strict Mode re-running development effects.
- Stale closures after async work resolves.
- Unmounts and route changes during in-flight operations.
Testing And Proof
- Reducer or pure function test for core state transitions.
- Interaction test for the user workflow.
- Regression case for stale or repeated async behavior.
Follow-Ups
- How would this change with server rendering?
- Where would you place this state in a larger app?