Forms
Type into a plain HTML input and the browser handles the value on its own: it stores what you typed and redraws the cursor without any code from you. Wire that input's value to a piece of state and update that state on onChange, and the input starts working like everything else in React: state holds the truth about what's on the page, and the input reflects it. That's a controlled input, one where React decides what's shown at every moment.
Here's a small form with one text input:
function NameForm() {
const [text, setText] = useState('')
function handleSubmit(event) {
event.preventDefault()
console.log(text)
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} />
<button>Submit</button>
</form>
)
}The input's value comes from the text state, and every keystroke fires onChange, which calls setText with the new value. The state and the input stay in sync because React re-renders the input with whatever text currently holds. Submitting a form reloads the page by default, so handleSubmit calls event.preventDefault() first, then does whatever the app needs, logging the text here, sending it to a server elsewhere.
Controlled and uncontrolled inputs
The input above is a controlled input, because its value lives in React state and React decides what it displays. Give it a value without an onChange, and the input turns read-only: React keeps painting the same value back on every render, and nothing you type ever reaches state, so pair the two whenever a field is controlled. An input can also be uncontrolled: the DOM keeps track of its own value, and you read it out only when you need it, through a ref for a single field (a way to reach a DOM element directly, covered in full in Refs and the DOM) or through the form's FormData for a whole form at submit time. Controlled inputs give the component the current value on every keystroke, which is what live validation, character counts, or fields that other parts of the UI depend on need. Uncontrolled inputs skip a render per keystroke and reach for the value only at submit time, which is less code for a simple field that nothing else needs to watch.
Checkboxes
Checkboxes follow the same pattern, but their state lives in checked instead of value:
function Newsletter() {
const [subscribed, setSubscribed] = useState(false)
return (
<label>
<input
type="checkbox"
checked={subscribed}
onChange={e => setSubscribed(e.target.checked)}
/>
Subscribe to updates
</label>
)
}e.target.checked is a boolean, and subscribed drives whether the box is ticked: one prop, one change handler, same shape as the text input.
Select
A <select> also takes a value, set on the select itself rather than on the selected option:
function ColorPicker() {
const [color, setColor] = useState('red')
return (
<select value={color} onChange={e => setColor(e.target.value)}>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
)
}Set value on the select, and React picks the matching option for you. There's no need to add a selected attribute to any of the option elements.
Textarea
In HTML, a textarea carries its text between the opening and closing tags. React treats it as an input like any other, so the text arrives through a value prop and onChange keeps it in sync:
function Feedback() {
const [message, setMessage] = useState('')
return (
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
rows={4}
/>
)
}rows sets how tall the box starts. defaultValue is the uncontrolled equivalent of value here: it sets the starting text once and leaves the DOM to track it from there.
Radio buttons
Several inputs share one name, which tells the browser they belong together. Only one can be picked. In React the group shares one piece of state, and each input's checked compares against it:
function ShippingSpeed() {
const [speed, setSpeed] = useState('standard')
return (
<fieldset>
<legend>Shipping speed</legend>
{['standard', 'express', 'overnight'].map(option => (
<label key={option}>
<input
type="radio"
name="speed"
value={option}
checked={speed === option}
onChange={e => setSpeed(e.target.value)}
/>
{option}
</label>
))}
</fieldset>
)
}fieldset and legend label the group for screen readers, and each label takes a key because the options come from an array (lists and keys).
checked={speed === option} is true for exactly one option, so one state value covers the whole group, whereas a checkbox carries its own boolean.
Submitting with an action
A <form> can also take an action function. React calls it when the form is submitted and hands it a FormData object holding the form's values:
function Signup() {
function handleSignup(formData) {
const values = Object.fromEntries(formData)
console.log(values.email, values.password)
}
return (
<form action={handleSignup}>
<input name="email" type="email" />
<input name="password" type="password" />
<button>Sign up</button>
</form>
)
}There's no event here, so there's nothing to call preventDefault() on. React stops the browser's default page reload for you.
Object.fromEntries(formData) turns that FormData into a plain object in one step. The keys come from each input's name attribute, which is why every input in the form needs one. Where several inputs share a name and can be sent together, like a checkbox group, only the last value survives, and formData.getAll reads them all.
The inputs above are uncontrolled: the DOM holds each value until submit. That matters for the one behavior that surprises people arriving from onSubmit: React resets the form once the action resolves, clearing uncontrolled fields like these. A controlled field re-renders from state and keeps its value.
Each path suits a different kind of form. An action fits a form whose job is to gather values and hand them off, usually to a server, where the wait is real and the result has to come back into the UI. The values arrive collected and named. onSubmit with controlled inputs fits a form that has to react while the user types, for live validation, a character count, or a field that changes what the rest of the form shows.
Version note
Form actions arrived in React 19. On React 18 and earlier, <form action={handleSignup}> does not throw. React drops the attribute, because a function is not a valid attribute value, and warns about it in development. The form then submits to the current URL, so the page reloads and the function never runs. Submission on those versions goes through onSubmit with event.preventDefault().
value comes from state, and onChange updates that state. Once that clicks for a text input, it works the same way for selects and textareas, and for checkboxes and radio buttons with checked carrying the state. Submitting is its own step: a form's submit runs a function you write, and that function decides what happens with the values. Next up: Lifting state up, where two components need to share the same piece of state.

