cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

Forms and inputs

Build React forms with controlled and uncontrolled inputs: checkboxes, selects, FormData, validation, and accessible labels, with live examples you can edit.

By the end, you can
  • 01
    Control any fieldKeep a text box, number box, checkbox, radio group, menu, or text area in state with the right prop and onChange.
  • 02
    Read a form when it's sentLeave fields uncontrolled with defaultValue, and read them all at once with FormData in a submit handler.
  • 03
    Check and label fieldsShow errors on submit, on blur, or with the browser's own checks, and give every field a label that works for everyone.

A name on a cup, typed into React state

A form is where people talk back to your page: a search box, a sign-up form, a note for the barista. The browser already knows how to show form fields and keep what people type in them. React's question is who keeps that text: your state, or the browser. Here is a field whose text lives in React state. Click in the box under the code and type a name.

A cup name kept in statePop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [name, setName] = useState("");   return (    <div>      <label>        Name on the cup{" "}        <input value={name} onChange={(e) => setName(e.target.value)} />      </label>      <p>{name === "" ? "Type a name for your cup." : `Your cup will say: ${name}`}</p>    </div>  );}
ResultLoading…

Line 4 makes a piece of state, name, that starts as an empty string. Line 10 is the input, with two props that work as a pair:

  • value={name} tells React what the box must show: whatever name holds.
  • onChange runs on every key. e.target is the input, and e.target.value is the text the browser wants to show now, with your new letter in it. The handler stores that text with setName.

Line 12 reads name too, so the sentence under the box changes as you type: type Asha and it says Your cup will say: Asha. Both the box and the sentence show the same state, so they can't disagree.

A loop of four steps. Top left, a box called name holds Te. An arrow labelled value goes to a page window whose text box shows Te. An arrow labelled you press a goes down to a card reading e.target.value: Tea. An arrow labelled onChange calls goes left to a card reading setName of Tea. An arrow labelled React renders again goes back up to the name box."Te"namevaluepageTeyou press ae.target.value: "Tea"onChangesetName("Tea")React renders again
One key, once round the loop. name is "Te", so the box shows Te. You press a: the browser offers "Tea" as e.target.value, onChange passes it to setName, and React renders again with name set to "Tea". The box only ever shows what the state holds.

An input like this is controlled: React state decides what it shows. The other kind, an uncontrolled input, lets the browser keep the text, and your code reads it when the form is sent. This lesson covers both, for every kind of field, and then how to check what people type and how to label fields so everyone can use them.

Real-life analogyOrdering at a café

At one café, the waiter writes your order down word by word and reads it back, so they can say "we're out of honey" before you finish. At another, you fill in a slip yourself and hand it in at the end. Both get your order. The first can answer while you speak; the second only reads it once.

In real life: The waiter writes each word on a pad as you say it
In React: onChange stores each key in state with setName
In real life: The waiter reads the order back from the pad
In React: value={name} makes the box show what state holds
In real life: You fill in a paper slip on your own
In React: An uncontrolled input keeps its own text in the browser
In real life: The waiter reads the slip when you hand it in
In React: FormData reads the fields when the form is sent
The main idea

Every form field in React is controlled or uncontrolled. A controlled field gets its value from state (value or checked), and onChange sets that state on every change, so your component knows the value at all times. An uncontrolled field keeps its own value in the browser, starting from defaultValue, and you read it when the form is submitted, usually with FormData.

Controlled inputs: the value and onChange pair

"React state decides" is meant word for word. The browser offers a new text on every key, but the box only shows it if your code puts it in state. So your onChange can store something different from what was typed. This coupon box stores every key in capital letters:

A coupon box that types in capitalsPop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [code, setCode] = useState("");   function handleChange(e) {    setCode(e.target.value.toUpperCase());  }   return (    <div>      <label>        Coupon code{" "}        <input value={code} onChange={handleChange} maxLength={8} />      </label>      <p>{code.length} of 8 characters</p>    </div>  );}
ResultLoading…

Line 7 is the only change from a plain controlled box: it stores e.target.value.toUpperCase(), the offered text in capitals. Type tea5 in the result. The box shows TEA5, because line 14 shows code, not the keys. maxLength on line 14 is the browser's own limit, and line 16 counts the characters as you type: 4 of 8 characters.

Try other rules for line 7 in the playground. Each key adds a row: the text the browser offered, and what your code stored.

Playground: who decides what the box shows?
The coupon boxJSX
import { useState } from "react"; export default function App() {  const [code, setCode] = useState("");   function handleChange(e) {    setCode(e.target.value.toUpperCase());  }   return <input value={code} onChange={handleChange} />;}
The pagecode: ""

Each key

No keys yet.

Try it yourself
Line 7 decides.

Type in the box. Each key adds a row: the text the browser offered, then what your code put in state.

The coupon box runs here, with React on this page, written the way the code shows. Pick a way to handle a key, then type, like tea5. Picking a way empties the box. The No onChange way runs with an onChange that only writes the log, which behaves the same and keeps React's warning out of this page's console.

With Upper case, type tea5: the last row says the browser offered "TEA5" and code is "TEA5", because each earlier letter was already in capitals. With Digits only, letters vanish as you type them and digits stay. With No onChange, every key is offered and then dropped: nothing sets state, so React puts the old text back after each key. That's the read-only box the Sharing state lesson showed.

Number boxes still give you strings

Every input hands onChange a string, even type="number". The number box only changes which keys the browser accepts and adds arrows to step the value. Here the price is worked out from the box:

Cups of chai at $4 eachPop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [cups, setCups] = useState("2");  const total = Number(cups) * 4;   return (    <div>      <label>        Cups of chai{" "}        <input type="number" min="1" max="9" value={cups} onChange={(e) => setCups(e.target.value)} />      </label>      <p>Total: ${total}</p>      <p>cups is a {typeof cups}: "{cups}"</p>    </div>  );}
ResultLoading…

Line 4 starts cups as the string "2", the same kind of value the box will give. Line 11 stores whatever the box offers, as it is. Line 5 turns it into a number only for the maths, with Number(), so line 13 shows Total: $8. Type 3 and it says Total: $12, and line 14 still says cups is a string: "3". Empty the box and the total is $0, because Number("") is 0, while the box stays empty instead of jumping to a 0 under your cursor.

Keep what the box shows in state, and convert where you use it. If you add instead of multiply, the string shows: cups + 1 is "3" + 1, which JavaScript joins into "31". Exercise 1 asks you to fix exactly that.

Checkboxes, radio buttons, selects, and text areas

Every kind of field can be controlled. The pattern is always the same, a prop that React sets and an onChange that sets state, but the prop and the place you read the new value differ. This tea order uses all four. Click a size, tick milk, pick a sweetener, and write a note:

A tea order with every kind of fieldPop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [size, setSize] = useState("small");  const [milk, setMilk] = useState(false);  const [sweetener, setSweetener] = useState("none");  const [note, setNote] = useState("");   return (    <div className="stack">      <fieldset>        <legend>Size</legend>        <label>          <input type="radio" name="size" value="small" checked={size === "small"} onChange={(e) => setSize(e.target.value)} />          Small        </label>        <label>          <input type="radio" name="size" value="large" checked={size === "large"} onChange={(e) => setSize(e.target.value)} />          Large        </label>      </fieldset>      <label>        <input type="checkbox" checked={milk} onChange={(e) => setMilk(e.target.checked)} />        Add milk      </label>      <label>        Sweetener        <select value={sweetener} onChange={(e) => setSweetener(e.target.value)}>          <option value="none">None</option>          <option value="sugar">Sugar</option>          <option value="honey">Honey</option>        </select>      </label>      <label>        Note for the barista        <textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} />      </label>      <p>        A {size} tea{milk ? " with milk" : ""}, sweetener: {sweetener}.{note && ` Note: ${note}`}      </p>    </div>  );}
ResultLoading…

Lines 4 to 7 keep one piece of state per field. Then, field by field:

  • Radio buttons (lines 14 and 18) share name="size", so the browser lets you pick one. Each is checked when size equals its own value, and onChange stores e.target.value, the picked button's value. <fieldset> and <legend> on lines 11 and 12 give the group its name, "Size".
  • The checkbox (line 23) uses checked instead of value, and reads e.target.checked, which is true or false. A checkbox's e.target.value is the text "on" whether it's ticked or not; Exercise 2 shows what that does.
  • The menu (line 28) takes value on the <select> itself. React picks the <option> whose value matches; you don't write selected on an option as you would in plain HTML.
  • The text area (line 36) takes value too. In plain HTML its first text goes between the tags; in React, use value or defaultValue instead, and React warns if you put text between the tags.

Line 39 reads all four. After Large, milk, honey, and the note Extra hot, it says A large tea with milk, sweetener: honey. Note: Extra hot. Wrapping each input in a <label> also means a click on the words, like "Add milk", clicks the field.

How to read the table: each row is one kind of field, with the prop React sets and where the new value is in the event.

Each kind of field, controlled
FieldThe prop React setsWhat onChange reads
Text box: <input>value: the text to show.e.target.value, always a string.
Number box: <input type="number">value: the number, as text or a number.e.target.value, still a string, like "3".
Checkbox: <input type="checkbox">checked: true or false.e.target.checked, true or false.
Radio button: <input type="radio">checked: true for the picked one only.e.target.value, the picked button's value.
Menu: <select>value on the <select>, not selected on an option.e.target.value, the picked option's value.
Text area: <textarea>value, not text between the tags.e.target.value, the whole text.

Why React warns about an input becoming controlled

A field should stay one kind, controlled or uncontrolled, for its whole life. A value of undefined (or null) counts as no value at all, so a field that gets one starts uncontrolled. If a later render gives it a real value, the field changes kind halfway through its life, and React warns. It happens easily with an object in state that doesn't have the field yet. Type a nickname in this one:

A nickname box that changes kind (a warning on purpose)Pop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [profile, setProfile] = useState({ name: "Asha" });   return (    <div>      <label>        Nickname{" "}        <input          value={profile.nickname}          onChange={(e) => setProfile({ ...profile, nickname: e.target.value })}        />      </label>      <p>Hello, {profile.nickname || profile.name}!</p>    </div>  );}
ResultLoading…

Line 4 starts profile with a name but no nickname, so on the first render line 11 passes value={undefined}: an uncontrolled box. The first key runs line 12, which adds nickname to the object, and the next render passes a real string. The box works, and the greeting on line 15 says Hello, Ash! after you type Ash, but the console under the result shows React's warning:

A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component.

The fix is to give every controlled field a real starting value. Press Edit & run and change line 4 to useState({ name: "Asha", nickname: "" }). Now the box is controlled from the first render, with an empty string, and typing prints no warning. For a checkbox, the same rule applies to checked: start it as false, not undefined.

How to read the table: each row is a warning React prints about fields, what caused it, and the fix.

Warnings about controlled and uncontrolled fields
What React saysWhat caused itThe fix
A component is changing an uncontrolled input to be controlled.value was undefined on the first render, then became text.Start the state as "", never undefined.
A component is changing a controlled input to be uncontrolled.value was text, then became undefined.Keep the state a string. An empty box is "".
You provided a value prop to a form field without an onChange handler.React shows value after every key, and nothing changes it, so the box is stuck.Add onChange, or use defaultValue if React doesn't need the text.
App contains an input of type text with both value and defaultValue props.The box was given both kinds at once.Pick one: value for controlled, defaultValue for uncontrolled.

Uncontrolled inputs and FormData

Many forms don't need to know anything until the person presses Send: a feedback form, a newsletter box, a contact form. For those, you can let the browser keep every field and read them all at once, at the end. You give each field a name, set its first value with defaultValue (or defaultChecked for a checkbox), and read the form in its submit handler. Press Send in the result, then tick the box and press it again:

A feedback form read with FormDataPop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [sent, setSent] = useState(null);   function handleSubmit(e) {    e.preventDefault();    const formData = new FormData(e.currentTarget);    setSent([...formData.entries()]);  }   return (    <form onSubmit={handleSubmit}>      <label>        Name <input name="name" defaultValue="Asha" />      </label>      <label>        Rating        <select name="rating" defaultValue="5">          <option value="5">5 stars</option>          <option value="3">3 stars</option>          <option value="1">1 star</option>        </select>      </label>      <label>        <input type="checkbox" name="offers" /> Email me offers      </label>      <label>        Comment <textarea name="comment" defaultValue="Lovely chai." />      </label>      <input placeholder="No name, so not sent" />      <button>Send</button>      {sent && (        <ul>          {sent.map(([name, value]) => (            <li key={name}>              {name}: {value}            </li>          ))}        </ul>      )}    </form>  );}
ResultLoading…

The fields on lines 15 to 31 have no value and no onChange: they're uncontrolled. defaultValue on lines 15, 19, and 29 only sets the starting text; after that, the browser keeps what you type. Pressing Send runs handleSubmit (lines 6 to 10):

  1. Line 7, e.preventDefault(), stops the browser's own action for a submit, which is to load a new page (the Responding to events lesson covers it).
  2. Line 8 makes a FormData from the form. e.currentTarget is the element the handler is on, the <form>. FormData collects every field with a name, as [name, value] pairs.
  3. Line 9 spreads those pairs into an array and stores it, so lines 33 to 41 can list them.

The first Send lists three lines: name: Asha, rating: 5, and comment: Lovely chai. Two fields are missing. The input on line 31 has no name, so FormData skips it. The checkbox on line 26 is left out while it isn't ticked; tick it and Send again, and offers: on appears between rating and comment, because "on" is what a ticked box sends when it has no value.

Left, a form window with five fields: name Asha, rating 5, offers not ticked, comment Lovely chai, and an input with no name. The offers and no-name fields are faded and crossed out. An arrow labelled new FormData goes right to a crate called formData holding three cards: name Asha, rating 5, and comment Lovely chai.formname: Asharating: 5offers: not tickedcomment: Lovely chai.(no name)new FormDataname: Asharating: 5comment: Lovely chai.formData
new FormData(form) reads every field that has a name, in page order. The unticked checkbox and the input with no name are left out, so formData holds three entries: name, rating, and comment.

To read one field, call formData.get("rating"): it returns the text, or null when nothing with that name was sent. Several checkboxes can share one name; formData.getAll("teas") returns all the ticked ones as an array (Exercise 5). And Object.fromEntries(formData) turns the pairs into one object, like { name: "Asha", rating: "5" }, using Object.fromEntries.

If you forget preventDefault

On a real page, a form without e.preventDefault() sends its fields to the server and loads a new page, so your React page starts over. The live results on this site never leave the page: press Edit & run, delete line 7, and press Send. The list still appears, because the preview stops the page load for you, and the console under the result says the form was submitted and that the preview doesn't send forms anywhere. On a real page, the list would be gone with the old page.

Controlled and uncontrolled forms also differ in how often React works. A controlled box sets state on every key, so React renders the component again for each one. An uncontrolled box changes nothing in React until Send. Count them:

Playground: count the renders
NameFormJSX
function NameForm() {
  const [name, setName] = useState("");
  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button>Send</button>
    </form>
  );
}
The page0 keys, 0 renders
Try it yourself
0 renders.

Type a name in the box, then press Send. The counter shows how many times NameForm has rendered since you picked this way.

NameForm runs here, with React on this page, written the way the code shows. Type a name, then press Send. The counter adds one each time NameForm renders after it first appeared; React's extra development-only call of each component isn't counted. Picking a way starts again.

Type Asha in the controlled form: 4 keys, 4 renders. Pick Uncontrolled and type it again: 4 keys, 0 renders. Press Send and it's 1 render, from setSent. A render per key is cheap for a small form like this, and it's what lets a controlled form react while you type. When nothing on the page needs the text until Send, uncontrolled fields do less work and need less code.

How to read the table: each row is something a form needs, the kind of field to pick for it, and why.

Controlled or uncontrolled: which to pick
What the form needsPickWhy
Show something as people type, like a character countControlledState changes on every key, so React can show it right away.
Change what they type, like upper case or digits onlyControlledYour onChange decides the new state, and the box shows it.
Set the text from code, like a Clear buttonControlledSet the state, and the box follows.
Read everything once, when they press SendUncontrolledFormData reads the fields by name, with no state for each one.
A long form where nothing reacts while typingUncontrolledLess code, and no render on every key.

React 19 adds a third way to send a form: give it a function as its action, and React passes that function the FormData for you. That's the next lesson, Form actions. Reading a form in onSubmit, as here, keeps working.

Checking what people type

Before a form is sent, you usually check it: is there an @ in the email, is the password long enough? Two questions decide how the check feels: what counts as an error, and when the error appears. Here is a sign-up form that checks on blur (when you leave a field) and on submit. Type asha in Email, press Tab, then press Sign up:

A sign-up form that checks on blur and on submitPop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; function validate({ email, password }) {  const errors = {};  if (!email.includes("@")) errors.email = "Enter an email with an @.";  if (password.length < 8) errors.password = "Use at least 8 characters.";  return errors;} export default function App() {  const [values, setValues] = useState({ email: "", password: "" });  const [touched, setTouched] = useState({});  const [submitted, setSubmitted] = useState(false);  const [done, setDone] = useState(false);  const errors = validate(values);  const show = (field) => (touched[field] || submitted) && errors[field];   function handleChange(e) {    setValues({ ...values, [e.target.name]: e.target.value });  }   function handleBlur(e) {    setTouched({ ...touched, [e.target.name]: true });  }   function handleSubmit(e) {    e.preventDefault();    setSubmitted(true);    if (Object.keys(errors).length === 0) setDone(true);  }   if (done) return <p>Welcome, {values.email}!</p>;   return (    <form onSubmit={handleSubmit} noValidate>      <label>        Email        <input name="email" type="email" value={values.email} onChange={handleChange} onBlur={handleBlur} />      </label>      {show("email") && <p className="error">{errors.email}</p>}      <label>        Password        <input name="password" type="password" value={values.password} onChange={handleChange} onBlur={handleBlur} />      </label>      {show("password") && <p className="error">{errors.password}</p>}      <button>Sign up</button>    </form>  );}
ResultLoading…

validate on lines 3 to 8 is a plain function: it takes the values and returns an errors object, with one message for each field that's wrong. Line 15 calls it on every render. The errors are worked out from the values, not kept in state, so they can never fall behind (the Choosing the state structure lesson calls this avoiding redundant state).

Three pieces of state decide when an error shows:

  • touched (line 12) remembers which fields you've left. handleBlur on line 23 marks a field when it loses focus; React's onBlur runs then.
  • submitted (line 13) becomes true when you press Sign up (line 28), so every error shows at once, even for fields you never visited.
  • show on line 16 returns a field's message only when the field is touched or the form was submitted.

While you type asha, nothing shows. Tab away and line 40 shows Enter an email with an @. Press Sign up and the password's error joins it. Fix both, with an email like asha@example.com and an 8-letter password, and press Sign up: line 29 finds no errors and line 32 says Welcome, asha@example.com!. One handleChange (lines 18 to 20) serves both fields: [e.target.name] picks the field by the input's name, as in Updating objects in state.

The same check can show its error at three moments. Try each one on the same email box:

Playground: when should the error show?
When the error showsJavaScript
const error = email.includes("@") ? "" : "Enter an email with an @.";const showError = error && (touched || submitted);
The pagetouched: false, submitted: false
Try it yourself
No error shown.

Quiet while you type. Leave the box with Tab or a click outside, and the check runs.

The email box runs here, with React on this page. Pick a timing, type asha, press Tab to leave the box, then press Join. Picking a timing empties the box.
A grid. Three moments run left to right along a time arrow: type asha, leave the box, press Sign up. Three rows are the timings. On submit: quiet, quiet, then an error card. On blur: quiet, then error cards. While typing: an error card at all three moments.type ashaleave the boxpress Sign upOn submitquietquieterrorOn blurquieterrorerrorWhile typingerrorerrorerrortime
The same email, asha, with no @. Checking on submit stays quiet until you press Sign up. On blur speaks up when you leave the box. While typing complains from the first letter, before you could have typed the @.

How to read the table: each row is one moment to show an error, how it feels to the person filling in the form, and what it suits.

When to show an error
When the error showsHow it feelsUse it for
On submitCalm while filling in, but every mistake waits until the end.Short forms, and as the last check on every form.
On blur, when you leave the fieldQuiet while you type, then feedback one field at a time.Most fields, like an email or a password.
While typingInstant, but it complains before you've finished.Hints, like a character count, and clearing an error once it's fixed.

The browser's own checks

The browser can check fields too, with no JavaScript at all, through attributes on the field (the Form validation lesson covers them in depth). React passes them straight to the page:

  • required: the field can't be empty.
  • type="email", type="url": the text must look like an email or a web address.
  • min and max, minLength and maxLength, and pattern: limits on numbers, length, and shape.
A form the browser checksPop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [joined, setJoined] = useState("");   function handleSubmit(e) {    e.preventDefault();    setJoined(new FormData(e.currentTarget).get("email"));  }   return (    <form onSubmit={handleSubmit}>      <label>        Email <input name="email" type="email" required />      </label>      <label>        Cups a day <input name="cups" type="number" min="1" max="9" required />      </label>      <button>Join the tea club</button>      {joined && <p>Joined: {joined}</p>}    </form>  );}
ResultLoading…

Press Join the tea club with the boxes empty. The browser shows its own message next to the first problem, and handleSubmit doesn't run at all: the browser checks before it sends the submit event. Try asha as the email, then 12 cups (line 17 allows 1 to 9). Once both are fine, line 20 says Joined: and the email. The red outline comes from input:user-invalid in the styles.css tab; it only appears after you've touched a field.

The sign-up form above puts noValidate on its form (line 35). That turns off the browser's check on submit, so your own messages show instead of the browser's. Its fields keep type="email", which still gives phones the keyboard with an @ on it. Whichever you use, checks in the page only help people fill the form in: anyone can skip them, so the server must check the data again.

Labels that clicks and screen readers can use

Every field needs a label: visible text that says what it's for, joined to the field in the page's code. A label joined to its field does three jobs. You can click the words to reach the field, a bigger target on a phone. A screen reader, the program that reads a page aloud for people who can't see it, says the label when the field gets focus. And the label stays put while you type, unlike a placeholder, which disappears at the first key and isn't read out by every screen reader.

There are two ways to join a label to its field:

  • Put the field inside the <label>, as every example so far has.
  • Give the field an id and the label a htmlFor with the same text. htmlFor is JSX's name for HTML's for attribute, since for is a JavaScript word.

An id must be unique on the page, which is a problem for a component you use twice. React's useId Hook gives each copy of a component its own id:

An email field you can use twicePop out in the code editor (opens in a new tab)JSX
import { useId } from "react"; function EmailField({ label }) {  const id = useId();  return (    <p>      <label htmlFor={id}>{label}</label>{" "}      <input id={id} type="email" aria-describedby={`${id}-hint`} />      <br />      <small id={`${id}-hint`}>We only use it for your receipts.</small>    </p>  );} export default function App() {  return (    <div>      <EmailField label="Home email" />      <EmailField label="Work email" />    </div>  );}
ResultLoading…

Line 4 asks React for an id. Each EmailField gets a different one, and keeps it every render. Line 7 joins the label to the box with htmlFor={id}, and line 8 gives the box that id. Click the words Work email in the result: the second box gets the cursor. Line 10 gives the hint its own id, made from the same one, and line 8 points at it with aria-describedby, so a screen reader reads the hint after the label. Don't use useId for list keys; keys come from your data.

Three cards. Left, label htmlFor id. Right, input id id. An arrow labelled same id joins them. Below the input, a card small id id-hint, joined by an arrow labelled aria-describedby. Notes say a click on the label focuses the box, and a screen reader says Home email, We only use it for your receipts.<label htmlFor={id}>same id<input id={id}>a click here focuses the boxaria-describedby<small id={`${id}-hint`}>A screen reader: "Home email … We only use it for your receipts."
useId gives each EmailField its own id. The label's htmlFor matches the input's id, so a click on the label focuses the box and a screen reader names the box "Home email". aria-describedby points at the hint, which the screen reader reads next.

Error messages work the same way. Give the message an id, point the field's aria-describedby at it, and set aria-invalid on the field while it's wrong, so a screen reader says the field is invalid and reads why. Radio buttons and checkboxes that belong together go in a <fieldset> whose <legend> names the group, as the tea order's sizes did. The Accessible React components lesson goes further.

A tea club sign-up form

Here is a whole form put together: controlled fields of three kinds, labels joined with htmlFor, checks on blur and on submit, and errors a screen reader can find. Press Join first with everything empty, then fill it in.

The tea club sign-upPop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; const empty = { name: "", email: "", plan: "monthly", rules: false }; function validate(values) {  const errors = {};  if (values.name.trim() === "") errors.name = "Tell us your name.";  if (!values.email.includes("@")) errors.email = "Enter an email with an @.";  if (!values.rules) errors.rules = "Please accept the club rules.";  return errors;} export default function App() {  const [values, setValues] = useState(empty);  const [touched, setTouched] = useState({});  const [submitted, setSubmitted] = useState(false);  const [member, setMember] = useState(null);  const errors = validate(values);  const show = (field) => (touched[field] || submitted) && errors[field];   function handleChange(e) {    const { name, type, value, checked } = e.target;    setValues({ ...values, [name]: type === "checkbox" ? checked : value });  }   function handleBlur(e) {    setTouched({ ...touched, [e.target.name]: true });  }   function handleSubmit(e) {    e.preventDefault();    setSubmitted(true);    if (Object.keys(errors).length > 0) return;    setMember(values);    setValues(empty);    setTouched({});    setSubmitted(false);  }   return (    <form onSubmit={handleSubmit} noValidate>      {member && <p>Welcome to the tea club, {member.name}! Plan: {member.plan}.</p>}      <label htmlFor="name">Name</label>      <input id="name" name="name" value={values.name} onChange={handleChange} onBlur={handleBlur}        aria-invalid={Boolean(show("name"))} aria-describedby="name-error" />      <p id="name-error" className="error">{show("name")}</p>      <label htmlFor="email">Email</label>      <input id="email" name="email" type="email" value={values.email} onChange={handleChange} onBlur={handleBlur}        aria-invalid={Boolean(show("email"))} aria-describedby="email-error" />      <p id="email-error" className="error">{show("email")}</p>      <label htmlFor="plan">Plan</label>      <select id="plan" name="plan" value={values.plan} onChange={handleChange}>        <option value="monthly">Monthly box</option>        <option value="yearly">Yearly box</option>      </select>      <label>        <input type="checkbox" name="rules" checked={values.rules} onChange={handleChange} />{" "}        I accept the club rules      </label>      <p className="error">{show("rules")}</p>      <button>Join</button>    </form>  );}
ResultLoading…

empty on line 3 holds every field's starting value, so each controlled field has a real value from the first render: two strings, "monthly" for the menu, and false for the checkbox. Line 22 reads four things from the input with destructuring, and line 23 stores checked for the checkbox and value for everything else, so one handler serves all four fields.

Press Join with nothing filled in: three errors appear, and lines 45 and 49 set aria-invalid on the name and email boxes. Each aria-describedby points at the paragraph under its box, where the message is. Fill in Asha, an email with an @, pick the yearly box, tick the rules, and press Join. Lines 34 to 37 save the member and put every field back to empty. Because the fields are controlled, setting the state empties the boxes, and line 42 says Welcome to the tea club, Asha! Plan: yearly.

Controlled or uncontrolled?
  • <input value={name} onChange={handleName} />
  • <input defaultValue="Asha" />
  • <input type="checkbox" checked={milk} onChange={handleMilk} />
  • <input type="checkbox" defaultChecked />
  • <select value={size} onChange={handleSize}>…</select>
  • <textarea name="comment" />
  • <input value={undefined} onChange={handleName} />
  • <input value="" onChange={handleName} />
Try it yourself
0 of 8 correct

Sort each field: does React state decide what it shows, or does the browser keep it?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "React's onChange runs when you leave the field, like the browser's change event." No. React's onChange runs on every key, like the browser's input event. For leaving a field, use onBlur.
  • "A number box gives you a number." No. e.target.value is always a string. Convert it with Number() where you do maths.
  • "Uncontrolled means React can't read the field." No. You read it when you need it: on submit, with FormData. Refs can reach it at other times; a later lesson, Manipulating the DOM with refs, shows how.
  • "An empty box can have a value of undefined." No. undefined means "no value", which makes the field uncontrolled. An empty controlled box has "".
  • "A placeholder is enough of a label." No. It disappears when you type, and not every screen reader reads it. Use a <label>.
  • "With required and type="email", the server can trust the data." No. Checks in the page help people; anyone can skip them. The server checks again.

Practice exercises

Each exercise shows its code's result, or shows it when you press Edit & run. Change the code, and the result updates as you type. Try a guess first, then use the hints.

Exercise 1 · Warm-upPredict one more cup

What does the paragraph show before you touch the box? Answer first, then press Edit & run to check.

import { useState } from "react";

export default function App() {
  const [cups, setCups] = useState("3");

  return (
    <div>
      <input type="number" value={cups} onChange={(e) => setCups(e.target.value)} />
      <p>With one more for a friend: {cups + 1}</p>
    </div>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind the stuck checkbox

    Click the box twice in the result. Is it ticked? Then press Edit & run and fix line 9 so it ticks and unticks.

    import { useState } from "react"; export default function App() {  const [milk, setMilk] = useState(false);   return (    <div>      <label>        <input type="checkbox" checked={milk} onChange={(e) => setMilk(e.target.value)} />        Add milk      </label>      <p>milk is {String(milk)}</p>    </div>  );}
    ResultLoading…

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeControl the size menu

      The menu changes, but the price line always says Small. Make the menu controlled, and make the price line follow it. What does it say after you pick Large?

      export default function App() {  return (    <div>      <label>        Size{" "}        <select defaultValue="small">          <option value="small">Small</option>          <option value="large">Large</option>        </select>      </label>      <p>Small: $3</p>    </div>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeSilence the warning

        Type a city: React warns in the console. Fix line 4. What should city start as?

        import { useState } from "react"; export default function App() {  const [address, setAddress] = useState({});   return (    <div>      <label>        City{" "}        <input value={address.city} onChange={(e) => setAddress({ ...address, city: e.target.value })} />      </label>      <p>Deliver to: {address.city || "no city yet"}</p>    </div>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeRead every ticked box

          Tick Green (Chai is ticked already) and press Save: the line only says green. Fix line 9 so it lists every ticked tea. What does it say then?

          import { useState } from "react"; export default function App() {  const [picked, setPicked] = useState("");   function handleSubmit(e) {    e.preventDefault();    const formData = new FormData(e.currentTarget);    setPicked(formData.get("teas"));  }   return (    <form onSubmit={handleSubmit}>      <label><input type="checkbox" name="teas" value="green" /> Green</label>      <label><input type="checkbox" name="teas" value="black" /> Black</label>      <label><input type="checkbox" name="teas" value="chai" defaultChecked /> Chai</label>      <button>Save</button>      {picked && <p>You picked: {picked}</p>}    </form>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWait until they leave the box

            This box scolds you before you've typed anything. Make the error wait until you leave the box. After your change, you type one letter and haven't left the box. Is the error showing?

            import { useState } from "react"; export default function App() {  const [email, setEmail] = useState("");  const error = email.includes("@") ? "" : "Enter an email with an @.";   return (    <div>      <label>        Email{" "}        <input value={email} onChange={(e) => setEmail(e.target.value)} />      </label>      {error && <p>{error}</p>}    </div>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each code question, find what the field gives onChange or FormData, then follow it to the screen. Press Edit & run on a question's code to check your answer.

              The forms and inputs quiz · 8 questionsScore: first tries count
              1. Question 1 of 8You clear the number box and type 5. What does the paragraph show?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { useState } from "react";
                
                export default function App() {
                  const [qty, setQty] = useState(1);
                  return (
                    <div>
                      <input type="number" value={qty} onChange={(e) => setQty(e.target.value)} />
                      <p>{qty + 1}</p>
                    </div>
                  );
                }

                Choose an answer to see the explanation.

              2. Question 2 of 8You press Send without ticking the box. What does the page say?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { useState } from "react";
                
                export default function App() {
                  const [offers, setOffers] = useState("not sent yet");
                
                  function handleSubmit(e) {
                    e.preventDefault();
                    const formData = new FormData(e.currentTarget);
                    setOffers(String(formData.get("offers")));
                  }
                
                  return (
                    <form onSubmit={handleSubmit}>
                      <label><input type="checkbox" name="offers" /> Email me offers</label>
                      <button>Send</button>
                      <p>offers: {offers}</p>
                    </form>
                  );
                }

                Choose an answer to see the explanation.

              3. Question 3 of 8You click the words "Milk: no" once. What does the label say then?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { useState } from "react";
                
                export default function App() {
                  const [milk, setMilk] = useState(false);
                  return (
                    <label>
                      <input type="checkbox" checked={milk} onChange={(e) => setMilk(e.target.checked)} />
                      Milk: {milk ? "yes" : "no"}
                    </label>
                  );
                }

                Choose an answer to see the explanation.

              4. Question 4 of 8Where does a controlled menu keep the picked option?

                Choose an answer to see the explanation.

              5. Question 5 of 8A text box's value starts as undefined and becomes "Asha" after the first key. What happens?

                Choose an answer to see the explanation.

              6. Question 6 of 8An email box has required and is empty. You press the form's submit button. What happens?

                Choose an answer to see the explanation.

              7. Question 7 of 8When does an on-blur check first show an error?

                Choose an answer to see the explanation.

              8. Question 8 of 8Why give a field a <label> instead of only a placeholder?

                Choose an answer to see the explanation.

              Key takeaways

              • A controlled field gets value (or checked) from state, and onChange sets that state on every key. The field shows only what state holds, so your code can change what's typed.
              • Text boxes, number boxes, menus, and text areas use value and e.target.value, always a string. Checkboxes use checked and e.target.checked. Radio buttons are checked when their value matches.
              • Start every controlled field with a real value, like "" or false. A value that starts as undefined makes React warn when it becomes text.
              • An uncontrolled field keeps its own value from defaultValue. Read the whole form on submit with new FormData(e.currentTarget): named fields only, and unticked boxes are left out.
              • Work errors out from the values on every render, and choose when they show: on submit, on blur, or while typing. The browser's required and type checks help too, and the server checks again.
              • Join every field to a <label>, by nesting it or with htmlFor and useId, and point errors at it with aria-describedby.

              Remember the one-liner.
              Controlled: state holds the value and the field shows it. Uncontrolled: the field holds it and you read it on submit.

              Coming next: Form actions. React 19 lets a form call a function with its FormData, shows a waiting state while it runs, and empties the form when it's done.

              CompleteFrontend Clear concepts. Working examples.