Search IOCombats

Search challenges, guides, questions and articles

How Does React Handle Forms and Controlled Inputs?

Intermediate12 min interview
Skills tested:
Understanding the controlled input cycle: user types, onChange fires, state updates, re-render, input shows new valueBuilding a multi-field form with a single shared handler using e.target.nameImplementing real-time validation inside onChangeFormatting input values inside onChange (phone numbers, credit cards)Disabling the submit button when validation errors exist

Advertisement

🧩 Scenario

React form handling is covered in virtually every React interview. The controlled input pattern is the foundation for everything from simple login forms to complex multi-step wizards. Real-world forms add validation, dynamic formatting, and dependent field logic, all of which require controlled inputs to work cleanly.

Architecture Walkthrough

The Controlled Input Cycle

A controlled input has four steps that happen on every keystroke: the user types a character, onChange fires with e.target.value containing the new text, the state setter is called with the new value, React re-renders the component, and the input displays the state value. React state is the single source of truth; the DOM reflects it.

This cycle enables every form feature that requires knowing the current value at any moment: live validation, character counting, input formatting, conditional field visibility, dependent field logic, and submit button state. None of these are possible without controlled inputs because with uncontrolled inputs, the value lives in the DOM and is only accessible via a ref read on demand.

Multi-Field Forms with a Shared Handler

For forms with multiple fields, writing a separate onChange for each field is repetitive. A shared handler reads e.target.name to identify which field changed and uses a computed property key to update only that field: setForm(prev => ({ ...prev, [name]: value })). Spreading prev is critical: without it, setting one field replaces the entire form state object, wiping out all other fields.

Real-Time Validation and Formatting

Validation can run inside onChange, immediately showing errors as the user types rather than only on submit. Formatting (auto-inserting dashes in a phone number, grouping digits in a credit card) is also done in onChange: strip the raw input to just digits, apply the formatting logic, and set the formatted string as state. The input then renders the formatted value on the next frame.


Key Code Explained

// Multi-field form with shared handler and real-time validation
interface SignupForm {
  email: string;
  password: string;
  confirmPassword: string;
}

interface FormErrors {
  email?: string;
  password?: string;
  confirmPassword?: string;
}

function SignupForm() {
  const [form, setForm] = useState<SignupForm>({
    email: '',
    password: '',
    confirmPassword: '',
  });
  const [errors, setErrors] = useState<FormErrors>({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // Shared handler: reads e.target.name to identify the field
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;

    // Update only the changed field — spread prev to preserve others
    setForm((prev) => ({ ...prev, [name]: value }));

    // Real-time validation
    setErrors((prev) => {
      const next = { ...prev };

      if (name === 'email') {
        next.email = value.includes('@') ? undefined : 'Enter a valid email';
      }
      if (name === 'password') {
        next.password = value.length >= 8 ? undefined : 'Minimum 8 characters';
      }
      if (name === 'confirmPassword') {
        next.confirmPassword =
          value === form.password ? undefined : 'Passwords do not match';
      }

      return next;
    });
  };

  const hasErrors = Object.values(errors).some(Boolean);
  const isComplete = form.email && form.password && form.confirmPassword;
  const canSubmit = isComplete && !hasErrors && !isSubmitting;

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!canSubmit) return;
    setIsSubmitting(true);
    // await submitToApi(form);
    setIsSubmitting(false);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"           // name matches the form state key
          type="email"
          value={form.email}
          onChange={handleChange}
        />
        {errors.email && <p className="error">{errors.email}</p>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          value={form.password}
          onChange={handleChange}
        />
        {errors.password && <p className="error">{errors.password}</p>}
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password</label>
        <input
          id="confirmPassword"
          name="confirmPassword"
          type="password"
          value={form.confirmPassword}
          onChange={handleChange}
        />
        {errors.confirmPassword && (
          <p className="error">{errors.confirmPassword}</p>
        )}
      </div>

      {/* Submit only enabled when form is valid */}
      <button type="submit" disabled={!canSubmit}>
        {isSubmitting ? 'Creating account...' : 'Sign Up'}
      </button>
    </form>
  );
}


// Input formatting: phone number auto-formatting in onChange
function PhoneInput() {
  const [phone, setPhone] = useState('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // Strip all non-digits first
    const digits = e.target.value.replace(/\D/g, '').slice(0, 10);

    // Insert dashes at correct positions
    let formatted = digits;
    if (digits.length > 6) {
      formatted = `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
    } else if (digits.length > 3) {
      formatted = `${digits.slice(0, 3)}-${digits.slice(3)}`;
    }

    setPhone(formatted);
  };

  return (
    <div>
      <label htmlFor="phone">Phone</label>
      <input
        id="phone"
        type="tel"
        value={phone}
        onChange={handleChange}
        placeholder="123-456-7890"
        inputMode="numeric"
      />
    </div>
  );
}

The noValidate on the form element disables the browser's built-in HTML5 validation, handing full control to React. Without it, the browser shows its own error popups that conflict with the custom inline errors, creating a confusing dual-validation experience.


Tradeoffs

FeatureControlled inputsUncontrolled inputs
Real-time validationTrivialRequires extra plumbing
Input formattingTrivialRequires direct DOM manipulation
Programmatic resetSet stateCall form.reset() or clear refs
Re-render costPer keystrokeNone until submit
Library supportFormik, most form libsreact-hook-form (default)

What Interviewers Actually Check

  • Whether you know the four-step controlled input cycle
  • Whether you use e.target.name with a shared handler for multi-field forms
  • Whether you spread prev state when updating one field
  • Whether you implement real-time validation in onChange
  • Whether you disable submit based on validation state

Follow-Up Questions

  1. How does react-hook-form differ from the manual controlled approach in terms of re-renders and validation strategy?
  2. How would you use Zod schema validation with react-hook-form to replace the manual error-setting logic?
  3. How would you debounce the validation so it only runs 300ms after the user stops typing, while keeping the input itself responsive?
  4. How would you build a multi-step form where each step is a separate component but all steps share the same form state?
  5. How does React 19's useFormStatus and useActionState change form handling for Server Actions?

Common Candidate Mistakes

  • Setting value on an input without onChange and not knowing why the input is read-only
  • Writing setForm({ [name]: value }) without spreading prev, which replaces the entire form object and clears all other fields
  • Only validating on submit, giving users no feedback until they fill the entire form and click submit
  • Storing a "formatted" display value and a "raw" value as two separate states and writing useEffect to sync them, instead of computing the formatted value directly in onChange
  • Not adding noValidate to the form element when using custom validation, creating conflicting browser and custom error messages

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you build a multi-field controlled form with a single shared onChange handler using e.target.name?
  • Can you implement real-time validation that shows errors inline as the user types?
  • Can you format input values (phone numbers, credit cards) inside onChange?
  • Can you disable the submit button while validation errors exist or the form is incomplete?
  • Can you explain why value without onChange makes an input read-only?

Summary

React forms work through controlled inputs. Each form field binds its value to state and calls a state setter in onChange. React is the single source of truth; the DOM reflects state on every render. This makes the current value always available, enabling live validation, dynamic formatting, submit button gating, and dependent field logic.

For multi-field forms, a single shared handler reads e.target.name to identify which field changed and uses a computed property key to update only that field: setForm(prev => ({ ...prev, [name]: value })). Spreading prev is required to preserve the other field values.

Validation runs inside onChange for the best UX: errors appear as the user types rather than only on submit. Input formatting (inserting dashes in phone numbers, grouping digits in credit cards) also runs in onChange: strip to raw digits, apply formatting, and set the formatted value as state. The submit button reads derived state (hasErrors and isComplete) to determine whether to be enabled, so no separate useEffect is needed to sync this state.

Frequently Asked Questions

Why does setting value without onChange make an input read-only?

React sets the input value from state on every render. Without onChange, the state never updates, so every keypress is immediately overwritten with the same state value, making the input appear frozen. React warns about this in development.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement