Forms and inputs
Build React forms with controlled and uncontrolled inputs: checkboxes, selects, FormData, validation, and accessible labels, with live examples you can edit.
- 01Control any fieldKeep a text box, number box, checkbox, radio group, menu, or text area in state with the right prop and
onChange. - 02Read a form when it's sentLeave fields uncontrolled with
defaultValue, and read them all at once withFormDatain a submit handler. - 03Check 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.
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> );}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: whatevernameholds.onChangeruns on every key.e.targetis the input, ande.target.valueis the text the browser wants to show now, with your new letter in it. The handler stores that text withsetName.
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.
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.
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:
onChangestores each key in state withsetName - 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:
FormDatareads the fields when the form is sent
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:
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> );}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.
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} />;}code: ""Each key
No keys yet.
Type in the box. Each key adds a row: the text the browser offered, then what your code put in state.
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:
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> );}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:
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> );}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 ischeckedwhensizeequals its ownvalue, andonChangestorese.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
checkedinstead ofvalue, and readse.target.checked, which istrueorfalse. A checkbox'se.target.valueis the text"on"whether it's ticked or not; Exercise 2 shows what that does. - The menu (line 28) takes
valueon the<select>itself. React picks the<option>whose value matches; you don't writeselectedon an option as you would in plain HTML. - The text area (line 36) takes
valuetoo. In plain HTML its first text goes between the tags; in React, usevalueordefaultValueinstead, 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.
| Field | The prop React sets | What 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:
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> );}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.
| What React says | What caused it | The 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:
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> );}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):
- 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). - Line 8 makes a FormData from the form.
e.currentTargetis the element the handler is on, the<form>. FormData collects every field with aname, as[name, value]pairs. - 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.
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.
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:
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>
);
}0 keys, 0 rendersType a name in the box, then press Send. The counter shows how many times NameForm has rendered since you picked this way.
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.
| What the form needs | Pick | Why |
|---|---|---|
| Show something as people type, like a character count | Controlled | State changes on every key, so React can show it right away. |
| Change what they type, like upper case or digits only | Controlled | Your onChange decides the new state, and the box shows it. |
| Set the text from code, like a Clear button | Controlled | Set the state, and the box follows. |
| Read everything once, when they press Send | Uncontrolled | FormData reads the fields by name, with no state for each one. |
| A long form where nothing reacts while typing | Uncontrolled | Less 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:
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> );}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.handleBluron line 23 marks a field when it loses focus; React'sonBlurruns 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.showon 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:
const error = email.includes("@") ? "" : "Enter an email with an @.";const showError = error && (touched || submitted);touched: false, submitted: falseQuiet while you type. Leave the box with Tab or a click outside, and the check runs.
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 the error shows | How it feels | Use it for |
|---|---|---|
| On submit | Calm 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 field | Quiet while you type, then feedback one field at a time. | Most fields, like an email or a password. |
| While typing | Instant, 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.minandmax,minLengthandmaxLength, andpattern: limits on numbers, length, and shape.
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> );}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
idand the label ahtmlForwith the same text.htmlForis JSX's name for HTML'sforattribute, sinceforis 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:
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> );}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.
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.
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> );}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.
<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} />
Sort each field: does React state decide what it shows, or does the browser keep it?
Common mix-ups
- "React's onChange runs when you leave the field, like the browser's change event." No. React's
onChangeruns on every key, like the browser'sinputevent. For leaving a field, useonBlur. - "A number box gives you a number." No.
e.target.valueis always a string. Convert it withNumber()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.
undefinedmeans "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.
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>
);
}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: {Number(cups) + 1}</p> </div> );}cups holds the string "3", and + with a string joins, so the starter shows With one more for a friend: 31. Number(cups) + 1 turns it into the number 3 first, so the fixed version shows 4.
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> );}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.checked)} /> Add milk </label> <p>milk is {String(milk)}</p> </div> );}A checkbox's value is the text it sends with a form, "on" unless you set one. Whether it's ticked is e.target.checked. Reading value, the first click sets milk to "on" and every later click sets it to "on" again, so the box stays ticked. With e.target.checked it toggles.
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> );}import { useState } from "react"; const prices = { small: 3, large: 5 }; export default function App() { const [size, setSize] = useState("small"); return ( <div> <label> Size{" "} <select value={size} onChange={(e) => setSize(e.target.value)}> <option value="small">Small</option> <option value="large">Large</option> </select> </label> <p>{size === "small" ? "Small" : "Large"}: ${prices[size]}</p> </div> );}The menu is controlled: value={size} on the <select>, and onChange stores e.target.value. The price line reads the same state, so picking Large shows Large: $5. In the starter, the menu changed but nothing else knew.
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> );}import { useState } from "react"; export default function App() { const [address, setAddress] = useState({ city: "" }); 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> );}useState({}) has no city, so the box starts with value={undefined}, uncontrolled, and becomes controlled on the first key. Starting city as "" makes it controlled from the first render, and the warning is gone.
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> );}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.getAll("teas").join(", ")); } 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> );}FormData holds one teas entry per ticked box, in page order. get("teas") returns only the first, "green". getAll("teas") returns ["green", "chai"], and join(", ") makes green, chai.
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> );}import { useState } from "react"; export default function App() { const [email, setEmail] = useState(""); const [touched, setTouched] = useState(false); const error = email.includes("@") ? "" : "Enter an email with an @."; return ( <div> <label> Email{" "} <input value={email} onChange={(e) => setEmail(e.target.value)} onBlur={() => setTouched(true)} /> </label> {touched && error && <p>{error}</p>} </div> );}The error is still worked out on every render, but it only shows once touched is true. onBlur sets it when you leave the box, so typing the first letter shows nothing, and leaving the box without an @ shows the error.
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.
Question 1 of 8You clear the number box and type 5. What does the paragraph show?
Read the code, then predictimport { 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.
Question 2 of 8You press Send without ticking the box. What does the page say?
Read the code, then predictimport { 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.
Question 3 of 8You click the words "Milk: no" once. What does the label say then?
Read the code, then predictimport { 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.
Question 4 of 8Where does a controlled menu keep the picked option?
Choose an answer to see the explanation.
Question 5 of 8A text box's
valuestarts asundefinedand becomes"Asha"after the first key. What happens?Choose an answer to see the explanation.
Question 6 of 8An email box has
requiredand is empty. You press the form's submit button. What happens?Choose an answer to see the explanation.
Question 7 of 8When does an on-blur check first show an error?
Choose an answer to see the explanation.
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(orchecked) from state, andonChangesets 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
valueande.target.value, always a string. Checkboxes usecheckedande.target.checked. Radio buttons arecheckedwhen their value matches. - Start every controlled field with a real value, like
""orfalse. Avaluethat starts asundefinedmakes React warn when it becomes text. - An uncontrolled field keeps its own value from
defaultValue. Read the whole form on submit withnew 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
requiredandtypechecks help too, and the server checks again. - Join every field to a
<label>, by nesting it or withhtmlForanduseId, and point errors at it witharia-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.