Handling forms (controlled inputs)
Controlled components, the canonical pattern for forms in React. Plus how textarea · select · checkbox · radio all follow the same model.
By Chapter 8 we have seen every core building block of React. Components, props, state, events, conditional and list rendering — all covered. This chapter wraps up Part 1. We lock in form handling, which appears in nearly every app, using the canonical pattern.
The controlled model from this chapter gets solidified with TypeScript in Chapter 19 (Typing events and forms), and extends one more time into the new model (<form action={fn}> + useActionState) in Chapter 27 (Server Actions and forms). Lock down this pattern and the later chapters read lightly.
Two ways to handle input elements #
There are broadly two ways to handle input elements in React.
- Controlled component — keep the source of truth for the input value in React state, and have the screen input follow that state.
- Uncontrolled component — leave the value in the DOM itself, and pull it out via
refwhen you need it.
In React, controlled is the canonical approach, and this chapter focuses on it. The uncontrolled style comes up again in Chapter 18 (typing hooks) when we cover useRef.
What is a controlled component? #
A pattern we already used in Chapters 6 and 8. The key is to pair value with onChange.
import { useState } from 'react';
function SimpleInput() {
const [text, setText] = useState('');
return (
<div>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<p>Input: {text}</p>
</div>
);
}
export default SimpleInput;The flow:
- The user types a key → the browser fires the
changeevent - The
onChangehandler reads the new value viae.target.valueand pushes it into state withsetText - State changes → the component re-renders
- On re-render,
<input value={text}>shows the updated value on screen
On the surface it looks like a normal input that just shows what you type, but internally every keystroke goes through a full cycle of state → re-render. That is a controlled component.
Why go to so much trouble? #
You might think, “Won’t the browser remember the input value on its own? Do we really need to round-trip through state?” Yes, leaving it alone, the browser keeps the value (that is uncontrolled). But controlled has clear upsides.
- Real-time transformation / validation becomes easy (length limits, automatic uppercasing, format checks)
- You can link two fields together (changing one updates the other)
- You can share the input value with other components via state (the pattern from Chapter 11)
- You can express submit button enablement in terms of input state right away (we already saw this in Chapter 7)
For most form scenarios, controlled is more intuitive and more powerful.
textarea #
textarea also uses value / onChange just like input. In HTML you put the value as a child, like <textarea>some text</textarea>, but in React you handle it via the value attribute.
import { useState } from 'react';
function MemoForm() {
const [memo, setMemo] = useState('');
return (
<textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={5}
/>
);
}
export default MemoForm;select #
A dropdown (<select>) follows the same pattern. The selected option’s value comes through as e.target.value.
import { useState } from 'react';
function CategoryPicker() {
const [category, setCategory] = useState('frontend');
return (
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="frontend">Frontend</option>
<option value="backend">Backend</option>
<option value="devops">DevOps</option>
</select>
);
}
export default CategoryPicker;Each <option>’s value matches against the state value. The initial state ('frontend') becomes the initially selected option.
checkbox #
The value of a checkbox is not a string but a boolean (whether it is checked). So you use checked instead of value, and e.target.checked instead of e.target.value.
import { useState } from 'react';
function AgreeCheckbox() {
const [agreed, setAgreed] = useState(false);
return (
<label>
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
I agree to the terms
</label>
);
}
export default AgreeCheckbox;radio #
Radio buttons are a group where you pick one of several. They share the same name, and checked is expressed as state === the option's value.
import { useState } from 'react';
function PaymentRadio() {
const [payment, setPayment] = useState('card');
return (
<div>
<label>
<input
type="radio"
name="payment"
value="card"
checked={payment === 'card'}
onChange={(e) => setPayment(e.target.value)}
/>
Card
</label>
<label style={{ marginLeft: '12px' }}>
<input
type="radio"
name="payment"
value="bank"
checked={payment === 'bank'}
onChange={(e) => setPayment(e.target.value)}
/>
Bank transfer
</label>
</div>
);
}
export default PaymentRadio;Managing several fields with one object #
When the form has many fields, calling useState many times gets tiresome. Bundling them into one object is a common pattern.
import { useState } from 'react';
function SignupForm() {
const [form, setForm] = useState({
name: '',
email: '',
password: '',
agreed: false,
});
function handleChange(e) {
const { name, type, value, checked } = e.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
}
return (
<form>
<input name="name" value={form.name} onChange={handleChange} placeholder="Name" />
<input name="email" value={form.email} onChange={handleChange} placeholder="Email" />
<input name="password" type="password" value={form.password} onChange={handleChange} placeholder="Password" />
<label>
<input name="agreed" type="checkbox" checked={form.agreed} onChange={handleChange} />
I agree to the terms
</label>
</form>
);
}
export default SignupForm;Two key tricks:
- Give each input element a
nameattribute to distinguish which field it is - Update only one slice of the object with
setForm(prev => ({ ...prev, [name]: ... }))(the pattern from Chapter 5)
One handler covers many fields; the code stays clean. That said, when per-field validation logic grows complex, splitting back into separate useState calls can become better. There is no single answer — pick what fits the situation.
Handling form submission #
A review of Chapter 6. Use onSubmit on the <form> and block the page reload with e.preventDefault() — that is the canonical pattern.
function handleSubmit(e) {
e.preventDefault();
if (!form.agreed) {
alert('Please agree to the terms.');
return;
}
console.log('Signup info:', form);
// In reality you would send this to the server here
}
return (
<form onSubmit={handleSubmit}>
{/* ... */}
<button type="submit">Sign up</button>
</form>
);Pressing <button type="submit"> or hitting Enter in an input submits the form. A button’s default type is submit, so even without specifying it, it behaves as a submit button inside a form — but get in the habit of being explicit to avoid confusion.
In Chapter 27 (Server Actions and forms) we cover the new model where the three steps <form onSubmit={...}> + e.preventDefault() + fetch('/api/...') collapse into one step, <form action={serverFn}>. The controlled pattern of this chapter survives in that model too — the value / onChange pair stays the same, only submission changes.
Refining input values #
One of the strengths of controlled is the freedom to transform input values. Just touch them right before set.
<input
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
/><input
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
/><input
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, 100))}
/>Since the on-screen value always matches the state value, the result is reflected the moment the user types it.
Try it yourself #
Let us build a signup form. A comprehensive example that pulls several kinds of inputs into one screen.
src/SignupForm.jsx:
import { useState } from 'react';
function SignupForm() {
const [form, setForm] = useState({
name: '',
email: '',
age: '',
gender: 'female',
interests: {
frontend: false,
backend: false,
design: false,
},
bio: '',
agreed: false,
});
const [submitted, setSubmitted] = useState(null);
function handleChange(e) {
const { name, type, value, checked } = e.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
}
function handleInterestChange(e) {
const { name, checked } = e.target;
setForm(prev => ({
...prev,
interests: { ...prev.interests, [name]: checked },
}));
}
function handleSubmit(e) {
e.preventDefault();
if (!form.agreed) {
alert('Please agree to the terms.');
return;
}
setSubmitted(form);
}
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px', maxWidth: '400px' }}>
<h2>Sign up</h2>
<form onSubmit={handleSubmit}>
<div>
<label>Name: </label>
<input name="name" value={form.name} onChange={handleChange} />
</div>
<div>
<label>Email: </label>
<input name="email" type="email" value={form.email} onChange={handleChange} />
</div>
<div>
<label>Age: </label>
<input
name="age"
type="text"
value={form.age}
onChange={(e) => setForm(prev => ({
...prev,
age: e.target.value.replace(/\D/g, ''),
}))}
/>
</div>
<div>
<label>Gender: </label>
<label>
<input type="radio" name="gender" value="female" checked={form.gender === 'female'} onChange={handleChange} />
Female
</label>
<label style={{ marginLeft: '8px' }}>
<input type="radio" name="gender" value="male" checked={form.gender === 'male'} onChange={handleChange} />
Male
</label>
</div>
<div>
<label>Interests: </label>
<label>
<input type="checkbox" name="frontend" checked={form.interests.frontend} onChange={handleInterestChange} />
Frontend
</label>
<label style={{ marginLeft: '8px' }}>
<input type="checkbox" name="backend" checked={form.interests.backend} onChange={handleInterestChange} />
Backend
</label>
<label style={{ marginLeft: '8px' }}>
<input type="checkbox" name="design" checked={form.interests.design} onChange={handleInterestChange} />
Design
</label>
</div>
<div>
<label>Bio: </label>
<textarea name="bio" value={form.bio} onChange={handleChange} rows={3} />
</div>
<div>
<label>
<input type="checkbox" name="agreed" checked={form.agreed} onChange={handleChange} />
I agree to the terms
</label>
</div>
<button type="submit" disabled={!form.agreed} style={{ marginTop: '8px' }}>
Sign up
</button>
</form>
{submitted && (
<pre style={{ marginTop: '16px', background: '#f4f4f4', padding: '8px' }}>
{JSON.stringify(submitted, null, 2)}
</pre>
)}
</div>
);
}
export default SignupForm;Wire it up in src/App.jsx.
import SignupForm from './SignupForm';
function App() {
return <SignupForm />;
}
export default App;Every kind of input is controlled. The age field automatically accepts only digits, and the submit button activates only when you agree to the terms. On submit, the result is printed to the screen as JSON.
Exercises #
- Add a simple validation to the email input of
SignupFormabove. Ifform.emaildoes not contain@, show a red note (“Not a valid email format”) below the input, and keep the submit button disabled in the meantime. Combine with the conditional rendering patterns from Chapter 7. - Practice input refinement. Add a phone number input, accept only digits, and automatically insert hyphens in the
010-1234-5678format insideonChange. Applying different regular expressions based on the current length is the simplest approach. - A combination of Chapter 9 + Chapter 8. Build a new form component
TaskForm, and on submit have the task accumulate into a list below. Each task gets an id viacrypto.randomUUID(), and next to each item a “Done” checkbox toggles a strikethrough on the text (textDecoration: 'line-through'). A comprehensive exercise that combines every Part 1 pattern in one component.
In one line: The canonical form pattern is the controlled component. Pair
value/onChangeto tie the input to state. For checkboxes usechecked/e.target.checked, for radio usename+value+checked={state === value}. Bundling many fields into one object and distinguishing them bynameis a common pattern. Submission uses<form onSubmit>+e.preventDefault(). The strengths of controlled are real-time transformation, validation, and linking.
Next chapter #
This chapter wraps up Part 1. Components, props, state, events, conditional / list / form rendering — the nine core building blocks of React are in your hands. Every component we built so far had everything begin and end within itself. Real apps need interaction with the outside world — fetching data from a server, setting timers, using browser APIs, and so on.
In the first chapter of Part 2, Chapter 10: useEffect, we learn the standard tool for handling these side effects — the useEffect hook. We also lay out the criteria for “when to use useEffect and when not to.”