Lesson 9 of 12

React Forms

Forms are where users talk back to your app: sign-ups, searches, feedback, checkout. React does not replace the HTML form elements you learned in HTML Forms – labels, inputs and buttons work as usual. What React forms add is a way to keep each field’s value in state, so you can validate, preview and submit them with ordinary JavaScript.

Controlled inputs

An input is controlled when React state decides its value. You do two things: set value={name} from state, and update the state in onChange. Now every keystroke goes through React: the user types, onChange calls the setter, the component renders again, and the input shows the new value. Because the value lives in state, you can use it anywhere – here, in a live preview.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A controlled input with live preview</title>
  <script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; line-height: 1.5; }
    input { padding: 6px; width: 220px; }
    .preview { margin-top: 12px; padding: 10px; border: 1px dashed #aaa; border-radius: 6px; }
  </style>
</head>
<body>
  <div id="root"></div>

  <script type="text/babel" data-type="module">
import React, { useState } from "https://esm.sh/[email protected]";
import { createRoot } from "https://esm.sh/[email protected]/client";

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

  return (
    <div>
      <label htmlFor="name">Name on your certificate</label><br />
      <input id="name" value={name} onChange={e => setName(e.target.value)} />
      <div className="preview">
        This certifies that <strong>{name.trim() || "your name"}</strong> completed the React track.
      </div>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<App />);
  </script>
</body>
</html>

Watch out: If you set value but forget onChange, the input becomes read-only – typing does nothing, and React warns you in the console. If you want an input that starts with a value but is not controlled, use defaultValue instead.

Checkboxes, selects and textareas

The pattern is the same for every field, with small differences:

ElementValue propRead in onChange
<input type="text">valuee.target.value
<input type="checkbox">checkede.target.checked
<select>value on the select (not selected on an option)e.target.value
<textarea>value (not text between the tags)e.target.value

When one of your React forms has many fields, one state object is tidier than many separate useState calls. Give each input a name that matches a key in the object, and write a single change handler that copies the object and updates the key named by e.target.name.

One detail catches many beginners: e.target.value is always a string, even for <input type="number">. If you need to calculate with it, convert it first with Number(e.target.value), and decide what to do when the field is empty.

Submitting and validating

Handle submission with onSubmit on the <form> – it catches both a button click and pressing Enter. Call e.preventDefault() first, otherwise the browser reloads the page. Then check the values, store any error messages in state, and show them next to the form. Connecting each error to its field with aria-describedby helps screen-reader users.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Registration form with validation</title>
  <script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; line-height: 1.5; }
    form { display: grid; gap: 10px; max-width: 320px; }
    input, select { padding: 6px; width: 100%; box-sizing: border-box; }
    .error { color: #b02a37; font-size: 14px; margin: 0; }
    .ok { color: #0a7a3d; font-weight: bold; }
  </style>
</head>
<body>
  <div id="root"></div>

  <script type="text/babel" data-type="module">
import React, { useState } from "https://esm.sh/[email protected]";
import { createRoot } from "https://esm.sh/[email protected]/client";

const empty = { name: "", email: "", course: "react", agree: false };

function App() {
  const [form, setForm] = useState(empty);
  const [errors, setErrors] = useState({});
  const [done, setDone] = useState("");

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

  function handleSubmit(e) {
    e.preventDefault();
    const found = {};
    if (form.name.trim().length < 2) found.name = "Please enter your name.";
    if (!form.email.includes("@")) found.email = "Please enter a valid email address.";
    if (!form.agree) found.agree = "Please accept the batch timings.";
    setErrors(found);

    if (Object.keys(found).length === 0) {
      setDone(`Thanks, ${form.name.trim()}! You are registered for ${form.course}.`);
      setForm(empty);
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">Full name</label>
        <input id="name" name="name" value={form.name} onChange={handleChange} aria-describedby="name-err" />
        {errors.name && <p id="name-err" className="error">{errors.name}</p>}
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" value={form.email} onChange={handleChange} aria-describedby="email-err" />
        {errors.email && <p id="email-err" className="error">{errors.email}</p>}
      </div>
      <div>
        <label htmlFor="course">Course</label>
        <select id="course" name="course" value={form.course} onChange={handleChange}>
          <option value="react">React</option>
          <option value="python">Python</option>
          <option value="ui-ux">UI/UX Design</option>
        </select>
      </div>
      <div>
        <label>
          <input type="checkbox" name="agree" checked={form.agree} onChange={handleChange} /> The weekend batch suits me
        </label>
        {errors.agree && <p className="error">{errors.agree}</p>}
      </div>
      <button type="submit">Register</button>
      <p className="ok" role="status">{done}</p>
    </form>
  );
}

createRoot(document.getElementById("root")).render(<App />);
  </script>
</body>
</html>

Look at [name]: … inside the handler. The square brackets are a computed property name: they use the value of the name variable – “email”, “course” and so on – as the key. That is how one function can update every field.

Tip: Client-side validation is for the user’s convenience only. Anyone can bypass it, so the server must always check the data again – see PHP Forms: GET and POST for that side.

React 19 form actions

React 19 adds another way to handle forms. Instead of onSubmit, you pass a function to the form’s action prop. React calls it with a FormData object holding every field by its name, so the inputs do not need to be controlled. You do not need preventDefault(), and after the action finishes successfully React resets the form’s uncontrolled fields for you.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>A form action that receives FormData</title>
  <script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; line-height: 1.5; }
    input, textarea { padding: 6px; width: 260px; display: block; margin-bottom: 8px; }
    li { margin-bottom: 4px; }
  </style>
</head>
<body>
  <div id="root"></div>

  <script type="text/babel" data-type="module">
import React, { useState } from "https://esm.sh/[email protected]";
import { createRoot } from "https://esm.sh/[email protected]/client";

function App() {
  const [reviews, setReviews] = useState([]);

  function addReview(formData) {
    const place = formData.get("place");
    const comment = formData.get("comment");
    setReviews(prev => [...prev, { id: prev.length + 1, place, comment }]);
  }

  return (
    <div>
      <form action={addReview}>
        <label htmlFor="place">Place</label>
        <input id="place" name="place" required defaultValue="Rock Beach" />
        <label htmlFor="comment">Your review</label>
        <textarea id="comment" name="comment" rows="2" required defaultValue="Lovely sunrise walk." />
        <button type="submit">Post review</button>
      </form>
      <ul>
        {reviews.map(r => <li key={r.id}><strong>{r.place}</strong>: {r.comment}</li>)}
      </ul>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<App />);
  </script>
</body>
</html>

Which approach should you use for React forms? Controlled inputs are best when the UI must react while the user types – live previews, instant validation, dependent fields. Form actions are simpler for “fill in and send” forms, and they work with newer hooks such as useActionState for pending and error states. Both are modern React.

Accessible React forms

Everything you know about accessible HTML forms still applies. Give every field a visible <label> linked with htmlFor, use the right input type so phones show a suitable keyboard, and do not rely on placeholder text as a label. Show error messages as text next to the field, not only as a red border, and keep what the user typed when validation fails so they only fix what is wrong.

Further reading

Key takeaways

  • A controlled input gets value from state and updates it in onChange.
  • Checkboxes use checked and e.target.checked; selects and textareas use value.
  • One state object plus [e.target.name] lets a single handler update many fields.
  • Handle onSubmit on the form and call e.preventDefault() before validating.
  • In React 19, <form action={fn}> passes FormData to your function and resets uncontrolled fields.
  • Always validate again on the server.

Quick quiz

Check what you learned – pick an answer.

1. What makes an input "controlled"?

2. Which property gives the ticked/unticked state of a checkbox in onChange?

3. What does React pass to a function given to <form action={fn}>?

Practice exercise

Build a newsletter form with a controlled email input and a "Subscribe" button. On submit, if the email has no "@", show an error message in red; otherwise show "Subscribed:" followed by the email address, and clear the input.

Show a hint

Use onSubmit with e.preventDefault(), and keep the email, error and success messages in state.

Show the solution
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Newsletter form</title>
  <script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; padding: 16px; line-height: 1.5; }
    .error { color: #b02a37; }
  </style>
</head>
<body>
  <div id="root"></div>

  <script type="text/babel" data-type="module">
import React, { useState } from "https://esm.sh/[email protected]";
import { createRoot } from "https://esm.sh/[email protected]/client";

function App() {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");
  const [message, setMessage] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    if (!email.includes("@")) {
      setError("Please enter a valid email address.");
      setMessage("");
      return;
    }
    setError("");
    setMessage("Subscribed: " + email);
    setEmail("");
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor="email">Email</label>{" "}
      <input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} />{" "}
      <button type="submit">Subscribe</button>
      {error && <p className="error">{error}</p>}
      <p role="status">{message || "Enter your email to get weekly lessons."}</p>
    </form>
  );
}

createRoot(document.getElementById("root")).render(<App />);
  </script>
</body>
</html>

Progress is saved in this browser only.