Skip to main content

πŸ“ Form Handling in React

Forms are where your app listens to its users β€” logins, sign-ups, checkouts, comments. In React you usually make each input a "controlled" field so state and screen stay perfectly in sync. This lesson takes you from a single text box to a validated, multi-field registration form.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Build a controlled input and explain why React becomes the single source of truth
  • Handle text, textarea, checkbox, radio, and select elements the React way
  • Manage many fields with a single state object and one handleChange
  • Validate input with both HTML5 attributes and custom logic, showing errors at the right time
  • Decide when a form library like React Hook Form earns its place

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a sign-up form with live validation and a disabled-until-valid submit button.

In This Lesson

Forms in React

In plain HTML, each form element quietly keeps its own value inside the DOM. React usually flips that around: you store the value in state and feed it back into the input. The result is that your component always knows, at any instant, exactly what's in every field.

πŸ’‘ Analogy: A paper form is filled out in private and handed over at the end. A React controlled form is more like a live interview: the app "hears" every answer as it's typed, can react instantly, and can even change later questions based on earlier ones.
flowchart LR A[User types] --> B[onChange fires] B --> C[setState updates value] C --> D[Input re-renders from state] D --> A C --> E[Submit / API call]

Because state drives the input, you get live validation, conditional fields, formatting-as-you-type, and a disabled-until-valid submit button β€” all for free.

Controlled Inputs

A controlled component is a form element whose value comes from state and whose onChange writes back to that state. Two lines wire up the loop:

import { useState } from 'react';

function NameInput() {
  const [name, setName] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    alert(`Hello, ${name}!`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input
          type="text"
          value={name}                          {/* value comes FROM state */}
          onChange={(e) => setName(e.target.value)} {/* changes go BACK to state */}
        />
      </label>
      <button type="submit">Greet me</button>
    </form>
  );
}
  1. name holds the current value.
  2. The input's value attribute is bound to name.
  3. onChange updates state on every keystroke, which re-renders the input.
  4. onSubmit reads the state and calls preventDefault() to stop the page reloading.

⚠️ Controlled means never undefined

Always initialize with a defined value like useState(''). If value is undefined, React treats the input as uncontrolled and later warns when it "switches to controlled." Start with an empty string, not null or nothing.

Every Form Element

Text inputs bind to value, but checkboxes and radios bind to checked, and multi-selects work with arrays. Here's the whole toolkit.

Text and textarea

Unlike HTML β€” where a <textarea> holds its text between the tags β€” React's <textarea> uses a value prop, so it behaves exactly like a text input:

<textarea
  value={bio}
  onChange={(e) => setBio(e.target.value)}
  rows={4}
/>

Checkbox and radio

These use checked (a boolean), not value:

function Preferences() {
  const [subscribed, setSubscribed] = useState(false);
  const [plan, setPlan] = useState('free');

  return (
    <form>
      <label>
        <input
          type="checkbox"
          checked={subscribed}
          onChange={(e) => setSubscribed(e.target.checked)}
        />
        Subscribe to the newsletter
      </label>

      {['free', 'pro', 'enterprise'].map((p) => (
        <label key={p}>
          <input
            type="radio"
            name="plan"
            value={p}
            checked={plan === p}
            onChange={(e) => setPlan(e.target.value)}
          />
          {p}
        </label>
      ))}
    </form>
  );
}

πŸ“– value vs. checked

Text, textarea, select: control with the value prop.

Checkbox, radio: control with the checked prop, and read e.target.checked in the handler.

Select dropdowns

React puts value on the <select> itself rather than a selected attribute on an option:

<select value={country} onChange={(e) => setCountry(e.target.value)}>
  <option value="">-- Select a country --</option>
  <option value="us">United States</option>
  <option value="ca">Canada</option>
  <option value="uk">United Kingdom</option>
</select>

For a multi-select, value is an array and you gather the chosen options:

const handleSkillsChange = (e) => {
  const chosen = Array.from(e.target.selectedOptions, (opt) => opt.value);
  setSkills(chosen);
};

<select multiple value={skills} onChange={handleSkillsChange}>
  <option value="react">React</option>
  <option value="node">Node.js</option>
  <option value="python">Python</option>
</select>

Many Fields, One State Object

Declaring a separate useState for every field gets tedious fast. A cleaner pattern stores the whole form in one object and uses a single handler that keys off each input's name attribute:

import { useState } from 'react';

function RegistrationForm() {
  const [form, setForm] = useState({
    firstName: '',
    email: '',
    age: '',
    interests: [],
  });

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;

    if (type === 'checkbox') {
      // Add or remove the value from the interests array
      setForm((prev) => ({
        ...prev,
        interests: checked
          ? [...prev.interests, value]
          : prev.interests.filter((i) => i !== value),
      }));
    } else {
      setForm((prev) => ({ ...prev, [name]: value }));
    }
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Submitting', form);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="firstName" value={form.firstName} onChange={handleChange} />
      <input name="email" type="email" value={form.email} onChange={handleChange} />
      <input name="age" type="number" value={form.age} onChange={handleChange} />

      <label>
        <input type="checkbox" name="interests" value="tech"
               checked={form.interests.includes('tech')} onChange={handleChange} />
        Technology
      </label>

      <button type="submit">Register</button>
    </form>
  );
}

βœ… The trick: name matches the state key

The computed-property syntax [name]: value updates whichever field fired the change. As long as each input's name matches a key in your state object, one handler serves the entire form.

⚠️ Use the functional update form

Prefer setForm(prev => ({ ...prev, ... })) over setForm({ ...form, ... }). When several updates happen close together, reading prev guarantees you're spreading the latest state, not a stale snapshot.

Validation

Good validation catches mistakes early without nagging users before they've had a chance to type. Two layers work together.

Layer 1 β€” HTML5 attributes

The browser can enforce simple rules for free with required, minLength, type="email", pattern, and friends:

<input
  type="text"
  required
  minLength={3}
  maxLength={20}
  pattern="[A-Za-z0-9]+"
  title="Letters and numbers only"
/>
<input type="email" required />
<input type="number" min={18} max={120} required />

This is great as a baseline, but the UI is browser-specific and hard to style. For a consistent, friendly experience, add a custom layer.

Layer 2 β€” Custom validation with "touched" tracking

The key idea: only show a field's error once the user has left that field (its onBlur fired), or once they try to submit. That avoids yelling "Email is required" the instant the form appears.

import { useState } from 'react';

function SignupForm() {
  const [form, setForm] = useState({ email: '', password: '' });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const validate = (values) => {
    const next = {};
    if (!values.email) next.email = 'Email is required';
    else if (!/^\S+@\S+\.\S+$/.test(values.email)) next.email = 'Enter a valid email';

    if (!values.password) next.password = 'Password is required';
    else if (values.password.length < 8) next.password = 'At least 8 characters';
    return next;
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    const nextForm = { ...form, [name]: value };
    setForm(nextForm);
    setErrors(validate(nextForm)); // keep errors fresh as they type
  };

  const handleBlur = (e) => {
    setTouched((prev) => ({ ...prev, [e.target.name]: true }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const found = validate(form);
    setErrors(found);
    setTouched({ email: true, password: true }); // reveal all errors
    if (Object.keys(found).length === 0) {
      console.log('Valid! Submitting', form);
    }
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <input name="email" value={form.email}
             onChange={handleChange} onBlur={handleBlur} />
      {touched.email && errors.email && <p className="error">{errors.email}</p>}

      <input name="password" type="password" value={form.password}
             onChange={handleChange} onBlur={handleBlur} />
      {touched.password && errors.password && <p className="error">{errors.password}</p>}

      <button type="submit">Sign up</button>
    </form>
  );
}

πŸ’‘ noValidate hands you the wheel

Adding noValidate to the <form> turns off the browser's built-in popups so your custom messages show instead. You still keep the HTML5 attributes as documentation and as a fallback.

⚠️ Client validation is never enough

Everything above improves the experience, but it runs in the user's browser and can be bypassed. Always re-validate on the server before trusting or storing any data.

Form Libraries

Hand-rolled state and validation are perfect for learning and for small forms. Once forms get large β€” dozens of fields, cross-field rules, async checks β€” a library removes boilerplate and improves performance. React Hook Form is the current go-to: small, fast, and built around uncontrolled inputs under the hood.

import { useForm } from 'react-hook-form';

function HookForm() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isSubmitting },
  } = useForm();

  const password = watch('password');

  const onSubmit = async (data) => {
    await new Promise((r) => setTimeout(r, 800)); // pretend API call
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email', {
        required: 'Email is required',
        pattern: { value: /^\S+@\S+\.\S+$/, message: 'Invalid email' },
      })} />
      {errors.email && <p className="error">{errors.email.message}</p>}

      <input type="password" {...register('password', {
        required: 'Password is required',
        minLength: { value: 8, message: 'At least 8 characters' },
      })} />
      {errors.password && <p className="error">{errors.password.message}</p>}

      <input type="password" {...register('confirm', {
        validate: (v) => v === password || 'Passwords do not match',
      })} />
      {errors.confirm && <p className="error">{errors.confirm.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting…' : 'Submit'}
      </button>
    </form>
  );
}
ApproachBest forTrade-off
Manual (useState)Small forms, learning the fundamentalsBoilerplate grows with field count
React Hook FormMost real-world forms; performance-sensitive UIsA new API to learn
FormikTeams already standardized on itLarger, more re-renders than RHF

You'll often pair either library with a schema validator like Zod or Yup to describe your rules declaratively.

Hands-on Exercise

πŸ‹οΈ Build a Validated Sign-up Form

Objective: Put controlled inputs, single-object state, and touched-based validation together.

Requirements:

  1. Fields: username, email, password, and a "I accept the terms" checkbox.
  2. Store everything in one state object with one handleChange.
  3. Show each error only after the field is touched or the user submits.
  4. Disable the submit button until the form is valid and the terms are accepted.
πŸ’‘ Hint

Compute const errors = validate(form) on each render and derive const isValid = Object.keys(errors).length === 0 && form.acceptedTerms. Bind the button's disabled={!isValid}. Remember the checkbox reads e.target.checked, not value.

βœ… Sample solution
import { useState } from 'react';

function SignUp() {
  const [form, setForm] = useState({
    username: '', email: '', password: '', acceptedTerms: false,
  });
  const [touched, setTouched] = useState({});

  const validate = (v) => {
    const e = {};
    if (v.username.length < 3) e.username = 'At least 3 characters';
    if (!/^\S+@\S+\.\S+$/.test(v.email)) e.email = 'Enter a valid email';
    if (v.password.length < 8) e.password = 'At least 8 characters';
    return e;
  };

  const errors = validate(form);
  const isValid = Object.keys(errors).length === 0 && form.acceptedTerms;

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setForm((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value }));
  };

  const handleBlur = (e) =>
    setTouched((prev) => ({ ...prev, [e.target.name]: true }));

  const handleSubmit = (e) => {
    e.preventDefault();
    setTouched({ username: true, email: true, password: true });
    if (isValid) console.log('Account created', form);
  };

  const show = (field) => touched[field] && errors[field];

  return (
    <form onSubmit={handleSubmit} noValidate>
      <input name="username" value={form.username}
             onChange={handleChange} onBlur={handleBlur} placeholder="Username" />
      {show('username') && <p className="error">{errors.username}</p>}

      <input name="email" value={form.email}
             onChange={handleChange} onBlur={handleBlur} placeholder="Email" />
      {show('email') && <p className="error">{errors.email}</p>}

      <input name="password" type="password" value={form.password}
             onChange={handleChange} onBlur={handleBlur} placeholder="Password" />
      {show('password') && <p className="error">{errors.password}</p>}

      <label>
        <input type="checkbox" name="acceptedTerms"
               checked={form.acceptedTerms} onChange={handleChange} />
        I accept the terms
      </label>

      <button type="submit" disabled={!isValid}>Create account</button>
    </form>
  );
}

🎯 Quick Quiz

Question 1: What makes an input a controlled component in React?

Question 2: With the single-object state pattern, what lets one handleChange update the right field?

Question 3: Why should you re-validate form data on the server even after client-side validation?

Best Practices

βœ… Do❌ Avoid
Initialize state with defined values ('', false, [])Leaving value as undefined (uncontrolled warning)
Bind checkboxes/radios with checkedUsing value for checkbox state
Show errors after blur or submit (touched)Flashing errors before the user types
Pair a <label htmlFor> with each inputUnlabeled fields (bad accessibility)
Re-validate on the serverTrusting client-side checks alone

βœ… Accessibility counts

Associate every input with a label, and tie error text to its field with aria-describedby. Screen-reader users then hear the field name and the error together β€” the same information sighted users get from the red message underneath.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Controlled inputs bind value to state and update it via onChange, making React the single source of truth.
  • Text/textarea/select use value; checkboxes and radios use checked; multi-selects use arrays.
  • Store many fields in one state object and key updates off the input's name.
  • Combine HTML5 attributes with custom validation, revealing errors only after a field is touched or submitted.
  • Reach for React Hook Form as forms grow β€” and always re-validate on the server.

πŸ“š Further Reading

πŸš€ What's Next?

We kept saying "controlled." Next we compare it head-to-head with the alternative β€” controlled vs. uncontrolled components β€” so you know exactly when a ref beats a piece of state.

πŸŽ‰ Form mastered!

You can now capture, validate, and submit user input with confidence β€” the backbone of almost every real app.