React Basics #9: Working with forms (controlled inputs)
Up through last time we covered every core building block of React: components, props, state, events, and conditional/list rendering. From this article on, we move into more practical patterns. The first topic is forms, which appear in almost every app.
Two ways to handle input elements #
There are broadly two approaches to handling input elements in React.
- Controlled component — the source of truth for the input value lives in React state, and the input on screen mirrors that state
- Uncontrolled component — the input value lives in the DOM itself, and you grab it with
refwhen you need it
In React, controlled components are the standard, and that’s what we’ll focus on. We’ll revisit the uncontrolled approach later when we cover refs.
What is a controlled component? #
You’ve already used this pattern in #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 value: {text}</p>
</div>
);
}
export default SimpleInput;Here’s the flow:
- The user presses a key → the browser fires a
changeevent - The
onChangehandler pulls the new value viae.target.valueand reflects it in state withsetText - State changes, the component re-renders
- On re-render,
<input value={text}>shows the updated value
On the surface, it’s just a normal input that displays what you type. But internally a full cycle of going through state and back to the screen runs every time. That’s a controlled component.
Why so much trouble? #
You might wonder, “The browser already remembers the input. Why bother routing through state and re-rendering?” Right — if you leave it alone, the browser keeps the value (that’s the uncontrolled approach). But there’s a lot to gain by going controlled.
- Live transformation/validation becomes easy (length limits, auto-uppercasing, format checks, etc.)
- You can make two input fields stay in sync (changing one updates the other)
- You can share the input value with other components via state (the pattern we’ll cover in #11)
- You can express the submit button’s enabled condition directly from input state (you saw this in #7)
In most form scenarios, controlled is more intuitive and more powerful.
textarea #
textarea uses value/onChange just like input. In HTML you’d put the value as a child (<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 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>
);
}Each <option>’s value matches against the state value. The initial state ('frontend') is the option that’s selected initially.
checkbox #
A checkbox’s value is a boolean (checked or not), not a string. 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>
);
}radio #
Radio buttons are a group where you pick one of several. Tie them together with the same name, and express checked 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>
);
}Managing many fields in one object #
When there are lots of form fields, calling useState many times gets tedious. A common pattern is to bundle them into a single object.
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} />
Accept terms
</label>
</form>
);
}
export default SignupForm;Two key tricks:
- Give each input a
nameattribute to identify which field it is - Use
setForm(prev => ({ ...prev, [name]: ... }))to update only part of the object (the pattern from #5)
A single handler can manage multiple fields and the code stays clean. If per-field validation logic gets complex, splitting back into separate useState calls might end up better. There’s no fixed answer; pick what fits the situation.
Handling form submission #
A recap from #6. Use <form>’s onSubmit and call e.preventDefault() to block the page refresh — that’s the standard.
function handleSubmit(e) {
e.preventDefault();
if (!form.agreed) {
alert('Please accept the terms.');
return;
}
console.log('signup info:', form);
// in practice you'd send this to a server here
}
return (
<form onSubmit={handleSubmit}>
{/* ... */}
<button type="submit">Sign up</button>
</form>
);Pressing <button type="submit"> or hitting Enter inside an input submits the form. A button’s default type is submit, so it acts as a submit button inside a form even without specifying it explicitly — but it’s a good habit to specify it to avoid confusion.
Cleaning up input #
One of the strengths of controlled components is that you can transform the input freely. Just touch the value right before calling 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))}
/>Because what’s on screen always matches the state, the user sees the result immediately.
Try it yourself #
Let’s build a signup form. It’s a comprehensive example bringing 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 accept 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;Several kinds of inputs all run as controlled components. The age input automatically accepts digits only, and the submit button is enabled only when the terms are accepted. Submitting prints the input as JSON on screen.
Wrapping up #
In this article we looked at the canonical form pattern: controlled components. The essentials:
- Tie input to state with the
value/onChangepair - Checkbox:
checked/e.target.checked; radio:name+value+checked={state===value} - Bundling many fields in one object, distinguished by
name, is a common pattern - Submit with
<form onSubmit>+e.preventDefault() - Controlled’s strengths: live transformation/validation/sync
So far, the components we’ve built have all started and finished their work inside themselves. But real apps need to interact with the outside world — fetching data from a server, setting timers, using browser APIs, and so on. In the next article, “React Basics #10: useEffect,” we’ll learn the standard tool for handling these side effects, the useEffect hook.