React Forms

Forms are how React apps collect input from users — text fields, checkboxes, selects, textareas, and submit buttons. Unlike plain HTML, where the browser’s DOM keeps track of what’s typed into each field, React forms usually put a component’s state in charge of every input’s value. This lesson covers the controlled input pattern in depth: how it works under the hood, how to build multi-field forms, how to validate and submit them, and the mistakes that trip up almost every React beginner.

Overview: Controlled vs. Uncontrolled Inputs

In plain HTML, an <input> manages its own value internally. You read it only when you need it, usually by grabbing the DOM node. React can work this way too — that’s called an uncontrolled input — but the idiomatic React approach is the controlled input, where a piece of component state is the single source of truth for what the field displays.

A controlled input sets its value prop from state and updates that state on every onChange event. This means the input never actually manages its own value — React does, by re-rendering the element with whatever the state currently holds. If you set value but never update the backing state in onChange, the field will appear frozen: the user can type, but the DOM node gets rendered right back to the old value on every re-render.

Why bother with this indirection? Because it makes the form’s data flow explicit and predictable. The component’s state always reflects exactly what’s on screen, so you can validate on every keystroke, transform input as it’s typed (uppercase it, strip characters), conditionally disable the submit button, or reset the whole form by simply resetting state — no manual DOM reads required. Multiple pieces of UI (a live character counter, a preview panel) can all derive from the same state without ever touching the DOM directly.

Uncontrolled inputs still have a place: simple forms where you only care about the value at submit time, integrating with non-React code, or <input type="file">, whose value browsers refuse to let JavaScript set for security reasons (so file inputs are always uncontrolled). For an uncontrolled field you use defaultValue instead of value, and read the current value through a ref when you actually need it:

import { useRef } from "react";

function UncontrolledName() {
  const nameRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    console.log("Name was:", nameRef.current.value);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" defaultValue="" ref={nameRef} />
      <button type="submit">Submit</button>
    </form>
  );
}

This lesson focuses mainly on controlled forms, since that’s what you’ll use for the vast majority of real React forms — anywhere you need validation, conditional UI, or a value that other parts of the component depend on.

Syntax

const [value, setValue] = useState("");

<input
  type="text"
  name="fieldName"
  value={value}
  onChange={(e) => setValue(e.target.value)}
/>

The pieces of a controlled field:

Part Purpose
value Ties the input’s displayed content to a piece of state. React re-renders the DOM node to always match this.
onChange Fires on every keystroke (or selection change). Reads the new value from e.target.value and updates state.
name Lets one shared handler know which field changed, useful when several inputs update the same state object.
e.target The underlying DOM node from the React SyntheticEvent; has .value, and for checkboxes, .checked.

Different form elements expose their value through different props. Use this table as a quick reference:

Element Controlled via
<input type="text"> value + onChange
<input type="checkbox"> checked + onChange
<input type="radio"> checked + onChange
<textarea> value + onChange (not children, unlike plain HTML)
<select> value + onChange on the <select>, not on <option>

Examples

Example 1: A Single Controlled Input

import { useState } from "react";

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

  return (
    <div>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Enter your name"
      />
      <p>Hello, {name || "stranger"}!</p>
    </div>
  );
}

export default NameInput;

Renders a text box and a greeting paragraph below it. As the user types, name updates on every keystroke, so the greeting updates live, character by character, with no separate “submit” step.

Example 2: A Multi-Field Form with One State Object

import { useState } from "react";

function SignupForm() {
  const [formData, setFormData] = useState({
    username: "",
    email: "",
    password: "",
  });

  function handleChange(e) {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  }

  function handleSubmit(e) {
    e.preventDefault();
    console.log("Submitting:", formData);
  }

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

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

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

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

export default SignupForm;

Renders three labeled inputs and a submit button. Instead of one useState per field, this form keeps a single formData object and one shared handleChange function. The name attribute on each input tells the handler which key to update, using a computed property name ([name]: value) inside a spread copy of the previous state. Submitting the form calls e.preventDefault() to stop the browser’s default full-page reload, then logs the current data.

Output (in the browser console after typing “ada”, “ada@example.com”, “secret123” and clicking Sign Up):

Submitting: { username: "ada", email: "ada@example.com", password: "secret123" }

Example 3: Select, Checkbox, Textarea, and Validation

import { useState } from "react";

function FeedbackForm() {
  const [feedback, setFeedback] = useState({
    topic: "general",
    message: "",
    subscribe: false,
  });
  const [error, setError] = useState("");

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

  function handleSubmit(e) {
    e.preventDefault();
    if (feedback.message.trim().length < 10) {
      setError("Message must be at least 10 characters.");
      return;
    }
    setError("");
    console.log("Feedback submitted:", feedback);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="topic">Topic</label>
      <select id="topic" name="topic" value={feedback.topic} onChange={handleChange}>
        <option value="general">General</option>
        <option value="bug">Bug Report</option>
        <option value="feature">Feature Request</option>
      </select>

      <label htmlFor="message">Message</label>
      <textarea
        id="message"
        name="message"
        value={feedback.message}
        onChange={handleChange}
        rows={4}
      />

      <label htmlFor="subscribe">
        <input
          id="subscribe"
          name="subscribe"
          type="checkbox"
          checked={feedback.subscribe}
          onChange={handleChange}
        />
        Subscribe to updates
      </label>

      {error && <p>{error}</p>}

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

export default FeedbackForm;

Renders a dropdown, a multi-line textarea, a checkbox, an optional error message, and a submit button. The shared handleChange now branches on type: checkboxes report their state through e.target.checked instead of e.target.value, so the handler picks the right property. On submit, a short validation check rejects a message under 10 characters and shows an inline error instead of logging the data; a valid submission clears the error and logs the result.

Under the Hood: What Happens on Each Keystroke and Submit

It helps to trace the full cycle for a controlled input:

  • Mount: the component runs once, useState creates the initial value (for example, an empty string), and React renders the input with that value baked into the DOM node’s value attribute.
  • Keystroke: the browser fires a native input event. React’s synthetic event system wraps it and calls your onChange handler with a SyntheticEvent whose target is the real DOM node.
  • State update: your handler calls setState (or the object-updater form, setFormData(prev => ({ ...prev, [name]: value }))). This schedules a re-render; it does not update the DOM immediately or synchronously mutate anything.
  • Render: React re-invokes the function component. The JSX is re-evaluated with the new state, producing a new virtual DOM tree where the input’s value prop reflects the just-typed character.
  • Reconciliation and commit: React diffs the new tree against the previous one. Since the input element itself hasn’t changed type or position, React reuses the same DOM node and simply updates its value attribute to match the new state. Visually this feels instantaneous, but it is always render-then-commit, never a direct DOM write from your handler.
  • Submit: the browser’s default form behavior is to navigate to the form’s action URL, which would reload the page and lose all component state. Calling e.preventDefault() inside onSubmit stops that, letting you handle the data entirely in JavaScript — by now state already holds the final values, since every field kept it in sync as the user typed.

Common Mistakes

Mistake 1: Setting value Without onChange

// Wrong: input becomes unresponsive to typing
const [name, setName] = useState("");

<input type="text" value={name} />

Because React re-renders the input with value={name} every time, and nothing ever updates name, every keystroke gets immediately overwritten back to the empty string. The field appears frozen, and React logs a console warning about providing a value without an onChange handler. Fix it by wiring up onChange:

<input
  type="text"
  value={name}
  onChange={(e) => setName(e.target.value)}
/>

Mistake 2: Mutating State Directly

// Wrong: mutates the existing object instead of creating a new one
function handleChange(e) {
  formData[e.target.name] = e.target.value;
  setFormData(formData);
}

This mutates the same object reference that’s already in state, then calls setFormData with that same reference. React compares state by reference for some optimizations and, more importantly, other parts of your app that read the old state may already hold onto that object — you’ve silently changed data out from under them, and in some cases React may not even re-render because the reference didn’t change. Always build a new object:

function handleChange(e) {
  const { name, value } = e.target;
  setFormData((prev) => ({ ...prev, [name]: value }));
}

Mistake 3: Letting an Input Flip from Uncontrolled to Controlled

// Wrong: state starts as undefined
const [email, setEmail] = useState();

<input type="text" value={email} onChange={(e) => setEmail(e.target.value)} />

On the first render, email is undefined, so React treats the input as uncontrolled (no value at all). The moment the user types, email becomes a string and the input suddenly becomes controlled. React warns: “A component is changing an uncontrolled input to be controlled.” Always initialize form state with a concrete value of the right type, typically an empty string:

const [email, setEmail] = useState("");

Best Practices

  • Group related fields into a single state object and use a shared handleChange keyed off each input’s name attribute, instead of one useState call per field.
  • Always initialize state to the correct type ("" for text, false for checkboxes, [] for multi-selects) so an input never flips between uncontrolled and controlled.
  • Call e.preventDefault() in every onSubmit handler unless you genuinely want the browser’s native form submission.
  • Validate on submit for most fields, and consider validating on onBlur for expensive checks; validating on every keystroke can feel naggy for fields like email or password.
  • Pair every input with a <label htmlFor="..."> and matching id for accessibility and to make the label clickable.
  • Disable the submit button (or show a spinner) while an async submission is in flight to prevent duplicate submits.
  • For large, complex forms with many validation rules, consider a dedicated library like React Hook Form or Formik instead of hand-rolling everything — but understand the controlled-input pattern first, since those libraries build on it.
  • Never mutate state objects or arrays in place; always create new ones with spread syntax so React can detect the change and other references stay untouched.

Practice Exercises

  • Build a controlled login form with email and password fields backed by a single state object. On submit, log the values and clear the password field only (leave the email filled in).
  • Extend the FeedbackForm example so the topic select also requires a non-default choice — show an error if the user submits while topic is still "general" and a checkbox labeled “I confirm this is accurate” is unchecked.
  • Build a form with a list of interest checkboxes (e.g. “Sports”, “Music”, “Tech”) where checked values are stored in a state array. Add code to correctly add or remove an item from that array without mutating it directly when a checkbox is toggled.

Summary

  • Controlled inputs tie an element’s value (or checked) to component state and update that state via onChange, making state the single source of truth for what’s on screen.
  • Uncontrolled inputs use defaultValue and a ref instead, useful for simple cases or elements like <input type="file"> that can’t be controlled.
  • A shared handleChange keyed by each input’s name attribute keeps multi-field forms concise; always update state immutably with spread syntax.
  • Checkboxes read e.target.checked, while text inputs, selects, and textareas read e.target.value.
  • Always call e.preventDefault() in onSubmit to stop the browser’s default page reload.
  • Common bugs include a value without onChange (frozen input), direct state mutation, and initializing state as undefined (uncontrolled-to-controlled warning).