Forms & Controlled Inputs

React · Lesson 13 of 29 6 min read Updated

Forms are how users talk to your app: sign-up pages, search boxes, checkout pages and feedback forms. React gives you two ways to work with form fields. In a controlled input, React state holds the value and the input always shows that state. In an uncontrolled input, the browser keeps the value and you read it when you need it.

In this lesson you will build controlled text inputs, selects, checkboxes and radio buttons, handle submit with validation, and read uncontrolled fields with FormData. The next lesson, Form Actions & useActionState, builds on this with React 19's form actions.

Controlled inputs

A controlled input has two props: value, which comes from state, and onChange, which updates that state on every keystroke. The state becomes the single source of truth, so you always know the current value and can react to it immediately, for example to show a live preview or a character count.

React JSXTry it yourself
import { useState } from "react";
import { createRoot } from "react-dom/client";

function TicketForm() {
  const [name, setName] = useState("");
  const [travelClass, setTravelClass] = useState("3A");
  const [meals, setMeals] = useState(false);
  const [berth, setBerth] = useState("lower");

  const fares = { SL: 450, "3A": 1180, "2A": 1690 };
  const fare = fares[travelClass] + (meals ? 180 : 0);

  return (
    <div style={{ display: "grid", gap: 8, maxWidth: 320 }}>
      <label>
        Passenger name
        <input value={name} onChange={(e) => setName(e.target.value)} placeholder="As on ID card" />
      </label>
      <label>
        Class
        <select value={travelClass} onChange={(e) => setTravelClass(e.target.value)}>
          <option value="SL">Sleeper</option>
          <option value="3A">AC 3 tier</option>
          <option value="2A">AC 2 tier</option>
        </select>
      </label>
      <label>
        <input type="checkbox" checked={meals} onChange={(e) => setMeals(e.target.checked)} /> Add meals (₹180)
      </label>
      <div>
        Berth:
        {["lower", "middle", "upper"].map((b) => (
          <label key={b}>
            <input type="radio" name="berth" value={b} checked={berth === b} onChange={(e) => setBerth(e.target.value)} /> {b}
          </label>
        ))}
      </div>
      <p>
        {name || "Passenger"}, {travelClass}, {berth} berth: ₹{fare}
      </p>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<TicketForm />);

Each type of field has its own pair of props:

FieldValue propRead the new value from
<input> text, email, numbervaluee.target.value
<textarea>value (not children)e.target.value
<select>value on the <select>e.target.value
checkboxcheckede.target.checked
radiochecked on each optione.target.value

In HTML you mark the chosen option with selected on an <option>. In React you put value on the <select> instead. Similarly, a <textarea> gets its text through value, not between its tags.

Submitting and validating

To handle submit, put an onSubmit handler on the <form> element and call e.preventDefault() first. Without it, the browser sends the form and reloads the page, wiping your app's state. Using <form> (rather than an onClick on a button) also means pressing Enter in a field submits the form, as users expect.

React JSXTry it yourself
import { useState } from "react";
import { createRoot } from "react-dom/client";

function SignUp() {
  const [form, setForm] = useState({ name: "", email: "", age: "" });
  const [errors, setErrors] = useState({});

  function handleChange(e) {
    setForm({ ...form, [e.target.name]: e.target.value });
  }

  function validate(values) {
    const found = {};
    if (values.name.trim().length < 2) found.name = "Enter your full name.";
    if (!values.email.includes("@")) found.email = "Enter a valid email address.";
    const age = Number(values.age);
    if (!Number.isInteger(age) || age < 16) found.age = "You must be 16 or older.";
    return found;
  }

  function handleSubmit(e) {
    e.preventDefault();
    const found = validate(form);
    setErrors(found);
    if (Object.keys(found).length === 0) {
      console.log("Submitted:", JSON.stringify({ ...form, age: Number(form.age) }));
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate style={{ display: "grid", gap: 6, maxWidth: 300 }}>
      <input name="name" value={form.name} onChange={handleChange} placeholder="Full name" />
      {errors.name && <small style={{ color: "crimson" }}>{errors.name}</small>}
      <input name="email" type="email" value={form.email} onChange={handleChange} placeholder="Email" />
      {errors.email && <small style={{ color: "crimson" }}>{errors.email}</small>}
      <input name="age" type="number" value={form.age} onChange={handleChange} placeholder="Age" />
      {errors.age && <small style={{ color: "crimson" }}>{errors.age}</small>}
      <button type="submit">Create account</button>
    </form>
  );
}

createRoot(document.getElementById("root")).render(<SignUp />);

Filling in "Isha Rao", "isha@example.com" and 19, then pressing "Create account", prints:

Output
Submitted: {"name":"Isha Rao","email":"isha@example.com","age":19}

A few details matter here. The value of a number input is still a string in e.target.value, so convert it with Number() before doing maths. The noValidate attribute turns off the browser's own validation bubbles so you can show your own messages; in real apps you can also keep HTML attributes such as required and type="email" as a first line of defence. And remember that checks in the browser are for convenience only: the server must validate the data again.

Uncontrolled inputs and FormData

You do not have to control every field. If you only need the values when the form is submitted, let the browser keep them and read them all at once with the FormData API. Use defaultValue (or defaultChecked) to set a starting value without controlling the field:

React JSXTry it yourself
import { createRoot } from "react-dom/client";

function Feedback() {
  function handleSubmit(e) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const entry = Object.fromEntries(data);
    console.log(`${entry.name} from ${entry.city} rated us ${entry.rating}/5`);
    e.currentTarget.reset();
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" defaultValue="Farhan" />
      <select name="city" defaultValue="Kochi">
        <option>Kochi</option>
        <option>Lucknow</option>
      </select>
      <input name="rating" type="number" min="1" max="5" defaultValue="4" />
      <button>Send feedback</button>
    </form>
  );
}

createRoot(document.getElementById("root")).render(<Feedback />);

Pressing "Send feedback" without changing anything prints:

Output
Farhan from Kochi rated us 4/5

Every field needs a name attribute, because FormData uses names as keys. Uncontrolled forms are less code and do not re-render on each keystroke. Choose controlled inputs when you need the value while the user types: live validation, dependent fields, formatting or a preview.

Labels and useId

Every input needs a label for accessibility. Wrapping the input inside <label> works, as above. If the label and input are separate, connect them with htmlFor and id. Hard-coded ids break when a component appears twice on a page, so generate a unique id with the useId hook:

React JSX
import { useId } from "react";

function PhoneField() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>Mobile number</label>
      <input id={id} type="tel" />
    </>
  );
}

Do not use useId for list keys; keys should come from your data.

Common mistakes

  • Forgetting e.preventDefault(). The page reloads on submit and your state is lost.
  • Reading value for a checkbox. Use e.target.checked for checkboxes.
  • Treating number inputs as numbers. e.target.value is always a string; convert with Number().
  • Mixing value and defaultValue. A field is either controlled (value + onChange) or uncontrolled (defaultValue), not both.
  • Missing name attributes. FormData skips fields without a name.

Key points

  • Controlled inputs use value (or checked) from state plus an onChange handler.
  • <select> and <textarea> take value just like <input>.
  • Handle submit with onSubmit on the <form> and call e.preventDefault().
  • Keep validation errors in state and show them next to the fields; validate again on the server.
  • Uncontrolled inputs use defaultValue; read them on submit with new FormData(form).
  • Connect labels and inputs, using useId when ids must be unique.

Exercise

Build a "Pizza order" form for a shop in Jaipur with: a controlled name input, a size <select> (small ₹199, medium ₹349, large ₹499), checkboxes for two toppings at ₹40 each, and a delivery radio choice (pickup or home delivery at ₹30). Show the live total below the form. On submit, prevent the reload and log a one-line summary; refuse to submit if the name is empty.

Show solution
React JSX
import { useState } from "react";
import { createRoot } from "react-dom/client";

const sizes = { small: 199, medium: 349, large: 499 };

function PizzaOrder() {
  const [name, setName] = useState("");
  const [size, setSize] = useState("medium");
  const [toppings, setToppings] = useState({ paneer: false, corn: false });
  const [delivery, setDelivery] = useState("pickup");
  const [error, setError] = useState("");

  const toppingCount = Object.values(toppings).filter(Boolean).length;
  const total = sizes[size] + toppingCount * 40 + (delivery === "home" ? 30 : 0);

  function handleSubmit(e) {
    e.preventDefault();
    if (!name.trim()) {
      setError("Please enter your name.");
      return;
    }
    setError("");
    console.log(`${name}: ${size} pizza, ${toppingCount} topping(s), ${delivery}, ₹${total}`);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" />
      <select value={size} onChange={(e) => setSize(e.target.value)}>
        {Object.keys(sizes).map((s) => <option key={s} value={s}>{s} (₹{sizes[s]})</option>)}
      </select>
      {Object.keys(toppings).map((t) => (
        <label key={t}>
          <input type="checkbox" checked={toppings[t]} onChange={(e) => setToppings({ ...toppings, [t]: e.target.checked })} /> {t}
        </label>
      ))}
      <label><input type="radio" checked={delivery === "pickup"} onChange={() => setDelivery("pickup")} /> Pickup</label>
      <label><input type="radio" checked={delivery === "home"} onChange={() => setDelivery("home")} /> Home delivery</label>
      <p>Total: ₹{total}</p>
      {error && <p style={{ color: "crimson" }}>{error}</p>}
      <button>Place order</button>
    </form>
  );
}

createRoot(document.getElementById("root")).render(<PizzaOrder />);

All fields are controlled, so the total is derived from state on every render. The toppings object is copied with spread when a box changes.

Quick Quiz

1. Which two props make a text input controlled?

2. How do you read whether a checkbox is ticked in its change handler?

3. Why call e.preventDefault() in a form's submit handler?

4. A form only needs its values when submitted. What is the simplest approach?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call