Skip to main content

πŸ“ Forms in React

Forms are where users hand your app the data it exists to work with β€” a login, a search, a checkout, a comment. In React, the trick is to make component state the single source of truth for every field. Learn that one loop and every form, from a one-line search box to a multi-step wizard, follows the same shape.

Week 4 · Day 3 (Wednesday: Handling Events in React) · Lecture 3

🎯 Learning Objectives

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

  • Explain the difference between controlled and uncontrolled components
  • Wire the value / onChange loop that makes state the single source of truth
  • Manage many inputs with one handler using the name attribute
  • Handle every input type: text, textarea, select, checkbox, radio, and file
  • Add client-side validation with clear, real-time feedback
  • Extract form logic into a reusable custom useForm hook

Estimated Time: 70 minutes

Practice: Build a validated registration form and a reusable form hook.

In This Lesson

Controlled vs Uncontrolled

An HTML input keeps its own value inside the DOM β€” you type, the browser remembers. React offers two ways to work with that:

  • A controlled component hands the value to React state. React holds the truth; the input just displays it. This is the default you'll reach for.
  • An uncontrolled component lets the DOM keep the value, and you read it with a ref only when you need it β€” handy for simple or file inputs.
graph TD A[Form input] --> B[Controlled] A --> C[Uncontrolled] B --> D[value comes from state] B --> E[onChange updates state] B --> F[React is the source of truth] C --> G[DOM keeps the value] C --> H[read it with a ref] C --> I[defaultValue sets the start]

πŸ’‘ Which should I use?

Default to controlled. Having the value in state means you can validate as the user types, disable the submit button until the form is valid, transform input, and drive other UI from it. Reach for uncontrolled only for the occasional simple case (a lone file picker, integrating a non-React widget).

The Controlled Input Loop

A controlled input is a two-way binding built from two props: value (state flows into the input) and onChange (keystrokes flow back into state). This closed loop is the heart of every React form.

The controlled input loop: state sets value, onChange updates state, re-render repeats state const [v, setV] <input> shows the value value={v} onChange β†’ setV(e.target.value) re-render with new value β†’ repeat
State sets the input's value; the input's onChange sets the state. Each keystroke completes the loop and re-renders.
import { useState } from 'react';

function ControlledInput() {
  const [value, setValue] = useState('');

  // Every keystroke pushes the new text back into state.
  const handleChange = (e) => setValue(e.target.value);

  return (
    <div>
      <input
        type="text"
        value={value}          {/* state flows IN */}
        onChange={handleChange} {/* changes flow OUT */}
        placeholder="Type something…"
      />
      <p>You typed: {value}</p>
    </div>
  );
}

⚠️ value without onChange = a read-only input

If you set value={something} but forget onChange, React locks the field β€” typing does nothing, and you'll see a console warning. A controlled input needs both halves of the loop. (For an intentionally read-only field, add readOnly.)

Many Inputs, One Handler

A real form has several fields. You don't need a separate handler for each β€” store the fields in one state object and let a single handler use the input's name to update the right key. The computed-property syntax [name]: value makes it clean.

function RegistrationForm() {
  const [form, setForm] = useState({
    username: '',
    email: '',
    password: '',
  });

  // One handler for the whole form, keyed by each input's name.
  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
  };

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

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="username">Username</label>
      <input id="username" name="username"
             value={form.username} onChange={handleChange} />

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email"
             value={form.email} onChange={handleChange} />

      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password"
             value={form.password} onChange={handleChange} />

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

βœ… Why the spread and updater form?

setForm((prev) => ({ ...prev, [name]: value })) copies the existing fields and overwrites just one. The prev => updater guarantees you're merging into the latest state, not a stale snapshot β€” important when changes come quickly.

Every Input Type

The controlled pattern adapts to each kind of input. The only thing that changes is which property you bind and read.

InputBind toRead from onChange
text, textarea, select, radiovaluee.target.value
checkboxcheckede.target.checked
multiple selectvalue (array)e.target.selectedOptions
fileβ€” (uncontrolled)e.target.files

Textarea & select

function CommentAndPick() {
  const [comment, setComment] = useState('');
  const [fruit, setFruit] = useState('');

  return (
    <>
      <textarea
        value={comment}
        onChange={(e) => setComment(e.target.value)}
        rows={4}
      />
      <p>{comment.length} characters</p>

      <select value={fruit} onChange={(e) => setFruit(e.target.value)}>
        <option value="">Choose…</option>
        <option value="apple">Apple</option>
        <option value="banana">Banana</option>
      </select>
    </>
  );
}

Note that in React a <textarea> uses value (not children), and a <select> uses value on the select element itself rather than a selected attribute on an option β€” more consistent than plain HTML.

Checkboxes β€” bind checked, not value

function Preferences() {
  const [prefs, setPrefs] = useState({ newsletter: false, notifications: false });

  const handleCheck = (e) => {
    const { name, checked } = e.target;   // read checked for checkboxes
    setPrefs((prev) => ({ ...prev, [name]: checked }));
  };

  return (
    <>
      <label>
        <input type="checkbox" name="newsletter"
               checked={prefs.newsletter} onChange={handleCheck} /> Newsletter
      </label>
      <label>
        <input type="checkbox" name="notifications"
               checked={prefs.notifications} onChange={handleCheck} /> Notifications
      </label>
    </>
  );
}

Radio buttons β€” share a name, compare the value

function PlanChooser() {
  const [plan, setPlan] = useState('free');

  return ['free', 'pro', 'team'].map((option) => (
    <label key={option}>
      <input
        type="radio"
        value={option}
        checked={plan === option}   {/* the selected one matches state */}
        onChange={(e) => setPlan(e.target.value)}
      /> {option}
    </label>
  ));
}

File input β€” read e.target.files

function Uploader() {
  const [file, setFile] = useState(null);

  const handleFile = (e) => {
    const chosen = e.target.files[0];   // FileList β†’ first file
    setFile(chosen);
  };

  return (
    <>
      <input type="file" accept="image/*" onChange={handleFile} />
      {file && <p>{file.name} β€” {(file.size / 1024).toFixed(1)} KB</p>}
    </>
  );
}

πŸ’‘ File inputs are always uncontrolled

For security reasons a browser won't let JavaScript set a file input's value, so you can't make it fully controlled. Read the selected files in onChange and store what you need (the File object, a preview URL) in state.

Validation & Feedback

Because state already holds every value, validating is just deriving errors from that state. Store an errors object, check on submit, and clear each error as the user fixes it.

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

  const validate = () => {
    const next = {};
    if (!form.email) next.email = 'Email is required';
    else if (!/\S+@\S+\.\S+/.test(form.email)) next.email = 'Email is invalid';

    if (!form.password) next.password = 'Password is required';
    else if (form.password.length < 6) next.password = 'At least 6 characters';

    setErrors(next);
    return Object.keys(next).length === 0; // valid when no errors
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
    // Clear this field's error as soon as the user edits it.
    if (errors[name]) setErrors((prev) => ({ ...prev, [name]: '' }));
  };

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

  return (
    <form onSubmit={handleSubmit} noValidate>
      <input name="email" value={form.email} onChange={handleChange}
             aria-invalid={!!errors.email} />
      {errors.email && <span className="error">{errors.email}</span>}

      <input name="password" type="password"
             value={form.password} onChange={handleChange}
             aria-invalid={!!errors.password} />
      {errors.password && <span className="error">{errors.password}</span>}

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

βœ… Client-side is convenience, not security

Validating in the browser gives instant feedback and a nicer experience, but a determined user can bypass it. Always validate again on the server before trusting or storing anything. Treat the two as partners, not alternatives.

Uncontrolled Components

Sometimes you don't need React to track every keystroke β€” you just want the values when the form submits. An uncontrolled form lets the DOM hold the data and reads it with a ref, using defaultValue for initial content:

import { useRef } from 'react';

function QuickForm() {
  const formRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    // Pull all fields at once via the native FormData API.
    const data = Object.fromEntries(new FormData(formRef.current));
    console.log(data); // { name: '…', email: '…' }
  };

  return (
    <form ref={formRef} onSubmit={handleSubmit}>
      <input name="name" defaultValue="Ada" />
      <input name="email" type="email" defaultValue="ada@example.com" />
      <button type="submit">Submit</button>
    </form>
  );
}

⚠️ value vs defaultValue

Use value for controlled inputs (React owns it) and defaultValue for uncontrolled (the DOM owns it, this just seeds the starting text). Passing both, or switching a field from one to the other mid-life, triggers React's "controlled/uncontrolled" warning.

A Reusable useForm Hook

Once you've written a few forms, the pattern repeats: values, errors, a change handler, a submit handler. Extract it into a custom hook and every future form gets shorter.

function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [submitting, setSubmitting] = useState(false);

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

  // handleSubmit takes YOUR onSubmit and wraps it with validation.
  const handleSubmit = (onSubmit) => async (e) => {
    e.preventDefault();
    const found = validate ? validate(values) : {};
    setErrors(found);
    if (Object.keys(found).length === 0) {
      setSubmitting(true);
      await onSubmit(values);
      setSubmitting(false);
    }
  };

  const reset = () => { setValues(initialValues); setErrors({}); };

  return { values, errors, submitting, handleChange, handleSubmit, reset };
}

Using it, a login form collapses to almost nothing:

function Login() {
  const validate = (v) => {
    const e = {};
    if (!v.email) e.email = 'Required';
    if (!v.password) e.password = 'Required';
    return e;
  };

  const { values, errors, submitting, handleChange, handleSubmit } =
    useForm({ email: '', password: '' }, validate);

  const onSubmit = async (v) => {
    await fakeApiLogin(v);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input name="email" value={values.email} onChange={handleChange} />
      {errors.email && <span className="error">{errors.email}</span>}

      <input name="password" type="password"
             value={values.password} onChange={handleChange} />
      {errors.password && <span className="error">{errors.password}</span>}

      <button type="submit" disabled={submitting}>
        {submitting ? 'Logging in…' : 'Log in'}
      </button>
    </form>
  );
}

πŸ“– When to reach for a library

Your useForm is perfect for learning and for small-to-medium forms. For big forms with complex validation, async checks, and performance concerns, mature libraries like React Hook Form handle the edge cases and minimize re-renders. Learn the manual pattern first β€” then you'll know exactly what those libraries do for you.

Practice & Quiz

πŸ‹οΈ Exercise 1: A live character counter

Goal: Build a controlled <textarea> for a tweet that shows characters remaining out of 280 and disables the Post button when the limit is exceeded.

function TweetBox() {
  const [text, setText] = useState('');
  const MAX = 280;
  // TODO: bind value/onChange, compute remaining, disable when over limit
}
πŸ’‘ Hint

Remaining is MAX - text.length. Disable with disabled={text.length > MAX}. Because state holds the text, both the counter and the button derive straight from it.

βœ… Solution
function TweetBox() {
  const [text, setText] = useState('');
  const MAX = 280;
  const remaining = MAX - text.length;

  return (
    <div>
      <textarea value={text} onChange={(e) => setText(e.target.value)} />
      <p style={{ color: remaining < 0 ? 'crimson' : 'inherit' }}>
        {remaining} left
      </p>
      <button disabled={remaining < 0 || text.length === 0}>Post</button>
    </div>
  );
}

πŸ‹οΈ Exercise 2: Password match validation

Goal: Two password fields (password and confirm) in one state object. Show "Passwords don't match" only when both are non-empty and differ.

πŸ’‘ Hint

Keep both in one object with a shared handleChange keyed by name. Derive the error during render: form.confirm && form.password !== form.confirm.

βœ… Solution
function PasswordPair() {
  const [form, setForm] = useState({ password: '', confirm: '' });
  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
  };
  const mismatch = form.confirm && form.password !== form.confirm;

  return (
    <>
      <input name="password" type="password"
             value={form.password} onChange={handleChange} />
      <input name="confirm" type="password"
             value={form.confirm} onChange={handleChange} />
      {mismatch && <span className="error">Passwords don't match</span>}
    </>
  );
}

🎯 Quick Quiz

Question 1: What two props make an input a controlled component?

Question 2: For a checkbox, which property do you bind and read?

Question 3: Where must you also validate, even with perfect client-side checks?

Best Practices & Pitfalls

βœ… Do

  • Default to controlled components so state is the single source of truth
  • Use the name attribute + [name]: value to share one handler across fields
  • Bind checked for checkboxes/radios and value for everything text-like
  • Give inputs <label>s (via htmlFor/id) and validate on both client and server

❌ Don't

  • Set value without onChange β€” the field becomes accidentally read-only
  • Mutate the state object directly; always spread into a new object
  • Mix value and defaultValue on the same input
  • Trust client-side validation as your only defense

⚠️ The controlled/uncontrolled warning

// ❌ Starts undefined, becomes controlled after first keystroke β†’ warning
const [name, setName] = useState();

// βœ… Initialize to an empty string so it's controlled from the start
const [name, setName] = useState('');

Initialize controlled string inputs to '', not undefined. Otherwise React sees the field switch from uncontrolled to controlled and warns you.

Summary

πŸŽ‰ Key Takeaways

  • Controlled components make React state the single source of truth via the value/onChange loop
  • Manage many fields with one state object and one handler keyed by name
  • Each input type binds a specific property: value for text/select/radio, checked for checkboxes, files for uploads
  • Validation is just deriving errors from state β€” instant feedback in the browser, always re-checked on the server
  • Uncontrolled inputs (refs + defaultValue) suit simple or file cases
  • A custom useForm hook packages the pattern so every future form is shorter

πŸ“š Additional Resources

πŸš€ What's Next?

Your forms produce data β€” often lists of it. Next up: Rendering Lists with map() β€” turning arrays into UI, the all-important key prop, and rendering collections efficiently.

πŸŽ‰ Forms conquered!

You can now collect, validate, and manage any user input the controlled way β€” the backbone of every real app.