← Back to question bank
React AppSeniorHard#4017 · 55m

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;
}
TypeScript · runnable

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.

Reducer
Test every action: change clears the error banner, submit-start/success/error transitions, and that the values object is replaced rather than mutated.
Validation
Pure tests for valid and invalid inputs. Cover whitespace-only names and obviously malformed emails. No DOM needed.
A11y
Assert label/input association, aria-invalid on errored inputs, aria-describedby pointing at error messages, and role=alert on the submit-error banner.
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?