Controlled Inputs: React Owns the Value
An <input> in the browser already keeps its own value — you type, it remembers. That creates a problem for React, because now there are two possible answers to what is in this field: the DOM's answer and your state's answer. When they disagree, bugs follow.
React's usual solution is the controlled input. You set the input's value from state and give it an onChange handler that writes back to state. The loop is: user types a character, the browser fires change, your handler calls the setter, React re-renders, and the input receives the new value as a prop. It happens in well under a frame, so it feels like ordinary typing, but the important consequence is that state is now the single source of truth. Whatever is in state is what is on screen, always.
That control is what makes everything else possible: showing a live character counter, disabling Submit until the form is valid, forcing an input to uppercase, clearing the form after a successful save, or filling a field from a URL parameter. None of those is convenient when the DOM owns the value.
Two warnings you will meet. If you set value but forget onChange, React tells you the field is read-only — and it will be, since the value never changes. And if the initial value is undefined, for instance value={user.name} where user has not loaded, React starts the input as uncontrolled and then complains when a real value arrives. Initialise text state to an empty string, never to undefined or null.
import { useState } from 'react';
function NameField() {
const [name, setName] = useState(''); // empty string, never undefined
return (
<div>
<label htmlFor="name">Full name</label>
<input
id="name"
value={name} // React sets what is shown
onChange={(e) => setName(e.target.value)} // and hears about every change
maxLength={60}
/>
<p>{name.length}/60</p>
<button disabled={name.trim() === ''}>Continue</button>
</div>
);
}
// value without onChange -> React warns: you have made the field read-only
// value={user.name} where user is null -> uncontrolled-to-controlled warning - Formatting the value on every keystroke can fight the user — stripping spaces or forcing a phone-number pattern while they type often moves the cursor somewhere they did not expect. Clean the value on blur or on submit instead, and keep
onChangeas close to a straight copy as you can.
One Handler for a Whole Form
A signup form with six fields does not need six pieces of state and six handlers. Put the fields in one object, give every input a name attribute matching its key, and write a single handler that uses a computed property name to update the right field.
The line doing the work is setForm({ ...form, [name]: value }). The square brackets are JavaScript's computed property syntax: they mean use the value of the variable name as the key. Without them you would create a literal key called name and overwrite the wrong field, which is a satisfying bug to find at two in the morning. The spread copies the other fields, because state must be replaced rather than mutated.
Two adjustments are needed for particular input types. A checkbox carries its state in e.target.checked, not e.target.value, so the handler has to branch on e.target.type. And every input, including type="number", gives you a string — '25', not 25. Convert with Number() where you use it, and remember that an empty number field gives an empty string, which Number('') turns into 0 rather than nothing.
Whether you use one object or several separate useState calls is a judgement call. Separate state is clearer for two or three unrelated fields; one object is much less repetitive for a real form, and it makes resetting the whole thing a one-liner.
const EMPTY = { name: '', email: '', age: '', course: 'fullstack', terms: false };
function SignupForm() {
const [form, setForm] = useState(EMPTY);
function handleChange(e) {
const { name, type, value, checked } = e.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value // computed key
}));
}
function handleReset() {
setForm(EMPTY); // one line, because it is one object
}
return (
<form>
<input name="name" value={form.name} onChange={handleChange} />
<input name="email" type="email" value={form.email} onChange={handleChange} />
<input name="age" type="number" value={form.age} onChange={handleChange} />
<input name="terms" type="checkbox" checked={form.terms} onChange={handleChange} />
<button type="button" onClick={handleReset}>Reset</button>
</form>
);
}
// Number('') === 0, so validate before converting:
// if (form.age === '') -> "Age is required"
// else Number(form.age) < 16 -> "Must be 16 or older" - The handler uses the updater form,
setForm(prev => ...). In a form where several fields can change in quick succession — an autofill, a paste, a browser password manager — that is the safer choice, for the same reason the state lesson gave.
The Other Input Types
HTML is inconsistent about where an input keeps its value, and React smooths some of that over — which means a few tags behave differently in JSX from how you learned them.
A <textarea> in HTML holds its text between the tags. In React it takes a value prop like any other input, and you write it as a self-contained element. A <select> in HTML marks the chosen item with selected on an <option>. In React you put value on the <select> itself and leave the options alone. Both changes exist so that every form control works the same way, and both catch people copying HTML examples.
Radio buttons are a group: every button in the group shares one name, and each one's checked is a comparison between its own value and the state. A set of independent checkboxes is different again — the natural state for it is an array of the selected values, and toggling means adding to or filtering out of that array, without mutating it.
One control cannot be controlled at all. A file input's value is set by the operating system's file picker and, for security reasons, cannot be set from JavaScript. Always read files from e.target.files in the change handler.
// textarea: value prop, not children
<textarea name="bio" value={form.bio} onChange={handleChange} rows={4} />
// select: value on the select, not selected on the option
<select name="course" value={form.course} onChange={handleChange}>
<option value="fullstack">Full Stack</option>
<option value="datasci">Data Science</option>
</select>
// radio group: one name, checked is a comparison
<label><input type="radio" name="mode" value="online"
checked={form.mode === 'online'} onChange={handleChange} /> Online</label>
<label><input type="radio" name="mode" value="campus"
checked={form.mode === 'campus'} onChange={handleChange} /> On campus</label>
// multiple checkboxes: an array in state
const [topics, setTopics] = useState([]);
function toggleTopic(topic) {
setTopics(prev =>
prev.includes(topic) ? prev.filter(t => t !== topic) : [...prev, topic]
);
}
{['React', 'Node', 'SQL'].map(topic => (
<label key={topic}>
<input
type="checkbox"
checked={topics.includes(topic)}
onChange={() => toggleTopic(topic)}
/>
{topic}
</label>
))}
// file input: always uncontrolled
<input type="file" accept="image/*" onChange={e => setFile(e.target.files[0])} /> <textarea value={...} />— not text between the tags<select value={...}>— notselectedon an option- Checkbox —
checked, and reade.target.checked - Radio — one
nameper group,checkedis a comparison - Several checkboxes — keep an array in state and toggle immutably
- File — cannot be controlled; read
e.target.files
- Give every input a real
<label htmlFor="...">matching itsid. It lets users tap the label to focus the field, it is what screen readers announce, and it takes five seconds.
Uncontrolled Inputs and When They Are Fine
The alternative is to let the DOM keep the value and read it only when you need it. That is an uncontrolled input: you set an initial value with defaultValue — never value, which would freeze the field — and pull the current value out with a ref when the form is submitted.
This is not a shortcut for beginners; it is a reasonable choice for simple forms. There is no state, no re-render per keystroke and much less code. It is also the only option for file inputs. What you give up is everything that needs the value while the user is typing: live validation, a character counter, a Submit button that enables itself, one field reacting to another.
The rule of thumb: if nothing on screen needs to change as the user types, uncontrolled is fine and lighter. If anything does, control the input. What you must not do is switch between the two on the same field — that is exactly what triggers React's uncontrolled-to-controlled warning, and it usually means a value that starts out undefined.
import { useRef } from 'react';
function FeedbackForm({ onSend }) {
const messageRef = useRef(null);
const fileRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
onSend({
message: messageRef.current.value, // read once, at submit
attachment: fileRef.current.files[0]
});
e.target.reset(); // clear the form, the DOM way
}
return (
<form onSubmit={handleSubmit}>
<textarea ref={messageRef} defaultValue="" rows={4} />
<input type="file" ref={fileRef} />
<button type="submit">Send</button>
</form>
);
} defaultValueis used on the first render only. Changing it later does not update the field, which is a common source of confusion when the initial data arrives from a server after the form has already rendered — a case where a controlled input is the right answer.
Validating and Submitting
Put onSubmit on the <form>, not onClick on the button. That way the Enter key works, which users expect and which many people never test. Then call e.preventDefault() as the first line, or the browser will reload the page and your whole app restarts.
For validation, a function that takes the form values and returns an object of error messages is simple and testable. An empty object means valid. Run it on submit, and store the result so the messages can be rendered next to their fields.
When to show errors matters more than the checking itself. Validating on every keystroke means the user sees Invalid email address after typing the first letter, which is irritating and reads as the form being broken. The usual compromise is to validate a field when it loses focus, validate everything on submit, and once a field has shown an error, re-check it as the user fixes it so the message disappears at the right moment.
Finally, treat submission as a state of its own. Disable the button while the request is in flight — otherwise an impatient double-click creates two orders — and re-enable it in a finally block so a failed request does not leave the form permanently stuck.
One last thing that is not about React at all: client-side validation is for the user's benefit, not for security. Anyone can bypass it. Every rule you enforce here must be enforced again on the server.
function SignupForm() {
const [form, setForm] = useState(EMPTY);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
function validate(values) {
const e = {};
if (!values.name.trim()) e.name = 'Please enter your name';
if (!values.email.includes('@')) e.email = 'Enter a valid email address';
if (values.age === '') e.age = 'Age is required';
else if (Number(values.age) < 16) e.age = 'You must be 16 or older';
if (!values.terms) e.terms = 'Please accept the terms';
return e;
}
async function handleSubmit(e) {
e.preventDefault();
const found = validate(form);
setErrors(found);
if (Object.keys(found).length > 0) return;
setIsSubmitting(true);
try {
await createAccount(form);
setForm(EMPTY);
} catch (err) {
setErrors({ form: 'Could not sign you up. Please try again.' });
} finally {
setIsSubmitting(false); // runs even if the request failed
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="name">Name</label>
<input id="name" name="name" value={form.name} onChange={handleChange} />
{errors.name && <p className="error">{errors.name}</p>}
{errors.form && <p className="error">{errors.form}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating account…' : 'Sign up'}
</button>
</form>
);
} onSubmiton the form, so Enter workse.preventDefault()first, or the page reloads- Validation as a pure function returning an errors object
- Show errors on blur and on submit, not on the first keystroke
- Disable the submit button while the request is in flight
- Re-enable it in
finally, so failures do not lock the form - Re-validate everything on the server — client checks are a convenience only
- Once a form has many fields, conditional sections and cross-field rules, the boilerplate becomes real. That is when a library such as React Hook Form earns its place — it handles registration, validation and submission state for you. Learn the manual version first, though; the library makes far more sense when you know what it is replacing.
