Forms are where most applications meet the user. In plain HTML the browser keeps the text you type inside each input. In React you usually keep it in state instead. An input whose value is driven by state is called a controlled input, and it gives you full power: validate as the user types, format the text, enable or disable a button, reset the form, and reuse the value anywhere.
A controlled text input
Two props make an input controlled: value, which says what to show, and onChange, which says what to do when the user types:
import { useState } from "react";
function App() {
const [name, setName] = useState("");
return (
<div>
<label>
Your name: <input value={name} onChange={event => setName(event.target.value)} />
</label>
<p>Hello, {name || "stranger"}!</p>
</div>
);
}
export default App;Your name:
Hello, Asha!The cycle on each keystroke is: the user types a letter, React calls onChange with an event, the handler calls setName(event.target.value), React renders again, and the input shows the new name. The input never keeps its own text; it always displays whatever state says. The greeting below it is simply a second place that reads the same state, which is why it updates as you type with no extra code.
What goes wrong without onChange
If you set value but forget onChange, the field becomes read-only. React tells you in the console:
function App() {
return <input value="fixed" />;
}
export default App;CONSOLE
error: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.The warning explains that you provided a value prop without an onChange handler. If you want an initial value that the user can still edit, and you do not need to track every keystroke, use defaultValue="fixed" instead (an uncontrolled input, discussed in the Refs lesson).
Other kinds of field
The pattern is the same for every field type, with small differences in the property that holds the value.
Textarea and select
In React, <textarea> takes a value prop (not children as in HTML), and <select> takes a value that matches one of its options (instead of selected on an <option>):
import { useState } from "react";
function App() {
const [comment, setComment] = useState("");
const [language, setLanguage] = useState("en");
return (
<form>
<p>
<textarea value={comment} onChange={e => setComment(e.target.value)} rows={2} placeholder="Comment" />
</p>
<p>
<select value={language} onChange={e => setLanguage(e.target.value)}>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="hi">Hindi</option>
</select>
</p>
<p>Comment: "{comment}" in {language}</p>
</form>
);
}
export default App;English
Spanish
Hindi
Comment: "Great course" in esCheckbox and radio buttons
These have a boolean state instead of text. Use checked and event.target.checked. For radios, each button is checked when its value equals the state:
import { useState } from "react";
function App() {
const [subscribed, setSubscribed] = useState(false);
const [plan, setPlan] = useState("free");
return (
<form>
<p>
<label>
<input type="checkbox" checked={subscribed} onChange={e => setSubscribed(e.target.checked)} />
{" "}Send me the newsletter
</label>
</p>
<p>
{["free", "pro"].map(option => (
<label key={option} style={{ marginRight: 12 }}>
<input type="radio" name="plan" value={option} checked={plan === option} onChange={e => setPlan(e.target.value)} />
{" "}{option}
</label>
))}
</p>
<p>Subscribed: {String(subscribed)}; plan: {plan}</p>
</form>
);
}
export default App;Send me the newsletter
free pro
Subscribed: true; plan: proMany fields, one state object, one handler
A form with ten fields does not need ten useState calls and ten handlers. Keep the values in a single object, give each input a name, and write one handler that uses that name as the key:
import { useState } from "react";
function App() {
const [form, setForm] = useState({ name: "", email: "", agree: false });
function handleChange(event) {
const { name, value, type, checked } = event.target;
setForm({ ...form, [name]: type === "checkbox" ? checked : value });
}
return (
<form>
<p><input name="name" value={form.name} onChange={handleChange} placeholder="Name" /></p>
<p><input name="email" type="email" value={form.email} onChange={handleChange} placeholder="Email" /></p>
<p>
<label>
<input name="agree" type="checkbox" checked={form.agree} onChange={handleChange} />
{" "}I agree
</label>
</p>
<pre>{JSON.stringify(form)}</pre>
</form>
);
}
export default App;I agree
{"name":"Asha","email":"a@example.com","agree":true}Two details do the work. The [name]: ... syntax is a computed property name: the part in square brackets is evaluated and used as the key, so typing in the field named email updates form.email. And { ...form, ... } makes a copy of the object with that single property replaced, so the old state is never mutated (the previous lesson's rule). Using event.target to read name, value, type and checked lets one function serve text fields and checkboxes.
If you call the setter twice quickly or in async code, prefer the updater form setForm(f => ({ ...f, [name]: value })) to avoid stale snapshots. Read the value from event.target before any async step, or copy it into a variable: it is the same snapshot idea.
Showing and using the values
Because the values are state, you can use them anywhere while rendering. Disable a button until the form is valid, show a character counter, or format text:
import { useState } from "react";
function App() {
const [text, setText] = useState("");
const max = 10;
return (
<div>
<input value={text} onChange={e => setText(e.target.value.slice(0, max))} />
<p>{text.length} / {max} characters</p>
<button disabled={text.length === 0}>Save</button>
<p>Uppercase preview: {text.toUpperCase()}</p>
</div>
);
}
export default App;5 / 10 characters
Save
Uppercase preview: HELLOThe field refuses characters beyond ten (the handler trims the value before storing it), and the button is disabled while the text is empty. All of that is ordinary JavaScript on top of state. A controlled input makes this kind of logic easy because React is the one deciding what the field shows.
Common patterns
- Reset the form: set the state back to its initial object:
setForm(initialForm). The inputs follow. - Number fields:
event.target.valueis always a string, even fortype="number". Convert withNumber(value)when you use it as a number, and keep the raw string in state if the user may type partial values like1.or an empty field. - Trimming and formatting: apply them in the handler (before
setState) or when displaying, depending on whether the stored value should be raw. - Many similar fields: map over an array of field definitions to render them.
Common mistakes
- Setting
valuewithoutonChange, making the field read-only. - Setting
value={undefined}ornullat first, which makes React treat the input as uncontrolled and later controlled (it warns). Start with an empty string. - Using
checkedwithevent.target.value(it is always the string "on"); useevent.target.checked. - Mutating the state object in the handler instead of copying it.
- Forgetting
nameon the inputs when using a shared handler. - Forgetting that
valueis a string for number inputs.
Exercise
- Add a "Country" select with three options to the multi-field form and show it in the JSON.
- Add a "Reset" button that returns every field to its initial value.
- Build a password field with a "Show password" checkbox that switches the input between
type="password"andtype="text". - Enable the Submit button only when the name is not empty, the e-mail contains
@, and the checkbox is ticked.
Key points
- A controlled input gets its value from state (value or checked) and reports typing through onChange, so state is the single source of truth.
- Text, textarea and select use value and event.target.value; checkboxes use checked and event.target.checked.
- Give each field a name and use one handler with [event.target.name]: event.target.value to update an object of values.
- An input with a value but no onChange is read-only and React warns; use defaultValue for an uncontrolled starting value.