Forms in a component
In short: A controlled input in React keeps its value in state (value and onChange), so the state is the only source for checking and sending the form.
Problem
Typed, but empty
The buyer fills in the order and presses "Place order". The server answers that the name is required. And the field "Name" shows the name.
Console
Type a name, an email and an address, accept the terms and press "Place order". The field keeps "Maria Ivanova", but the request carries name: ''. The email and the address have value and onChange, and the name does not. The code sends the state, and the state does not know what is written in the field.
The idea
One truth: the state
In the DOM lesson the form kept the text itself, and the code read it on submit. In a component there is a second place: the state. If the two are not connected, each one says its own thing, as in the example.
The fix is that the field has no truth of its own. It shows the value from the state and on every key it tells the state the new text. Such a field is called controlled (a controlled input).
const [name, setName] = useState('');
<input id="name" value={name} onChange={(e) => setName(e.target.value)} />value={name} is the direction from the state to the field. onChange is the way back: e.target.value is the text of the field at the moment of the event. In React onChange comes on every key, not only when the field loses focus.
The other kinds of fields are the same, with another property:
<select value={payment} onChange={(e) => setPayment(e.target.value)}>…</select><input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />Now the state is the only source: what is in it is in the field, and the other way round. The button can be disabled by the state (!agree), and the request sends exactly what the buyer sees.
See it
See the circle
One letter goes the whole way: field, event, state, new render and the field again. Then the same field without a binding.
function CheckoutForm() { const [name, setName] = useState('');
return ( <form onSubmit={submit}> <label htmlFor="name">Name</label> <input id="name" value={name} onChange={(e) => setName(e.target.value)} /> </form> );}Step by step, in words
- The form keeps the name in state: name = ''. The field on the page shows what the state says, and it says: empty.
- The buyer presses M. The browser puts the letter in the field and fires the input event.
- onChange catches the event. e.target.value is the text of the field now: “M”.
- setName("M") changes the state. That is the only place where the name lives.
- The state changed, so React calls CheckoutForm again. It returns the field with value = name: “M”.
- The field gets its value from the state. It is the same letter, but now the state decides what the field shows.
- Every key goes around the whole circle: field, event, state, new render, field. One source of truth: the state.
- Now the same field without value and onChange. The field works, but nobody listens to it and nobody gives it a value.
- The buyer types “Maria”. The letters stay in the DOM. The state still says: name = ''.
- On submit the code sends the state: { name: '' }. The server answers “Name is required”, while the field shows “Maria”.
- The rule: every field has value from the state and onChange back to it. Then the field and the state cannot differ.
The circle looks like a needless detour: the letter is in the field without it. But because of it the state and the field cannot differ.
Try it
Bind the fields
The field Name, the select Payment and the checkbox I accept the terms are not bound to the state. Bind all three. The Place order button is disabled until the terms are accepted. The check types a name, an email and an address, chooses Card on delivery, checks and unchecks the box, and then orders. It expects Order no. 61 is placed. and a request with the typed name and payment: 'card'.
Console
Solution
App.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Checkout</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [problem, setProblem] = useState('');
async function submit(e) {
e.preventDefault();
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
if (res.ok) setOrderId(data.id);
else setProblem(Object.values(data.errors).join(' '));
}
if (orderId) return <p role="status">Order no. {orderId} is placed.</p>;
return (
<form onSubmit={submit} noValidate>
<p>In the cart: White bread × 2, Fresh milk × 1</p>
<label htmlFor="name">Name</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} />
<label htmlFor="email">Email</label>
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<label htmlFor="address">Address</label>
<input id="address" value={address} onChange={(e) => setAddress(e.target.value)} />
<label htmlFor="payment">Payment</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Cash on delivery</option>
<option value="card">Card on delivery</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
I accept the terms
</label>
{problem && <p role="alert">{problem}</p>}
<button type="submit" disabled={!agree}>
Place order
</button>
</form>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
The name gets value={name} and onChange={(e) => setName(e.target.value)}. The select gets value={payment} and an onChange with setPayment. The checkbox gets checked={agree} and onChange={(e) => setAgree(e.target.checked)}.
The check catches value={name} without onChange: the field becomes read-only, and the console says You provided a `value` prop to a form field without an `onChange` handler. It also catches setAgree(e.target.value): the value of the checkbox is "on" on every click, so once checked, it never leaves the state.
Try it
Fill in
What goes in the blank so that the checkbox is bound?
const [agree, setAgree] = useState(false);
<input
type="checkbox"
checked={agree}
onChange={(e) => setAgree(e.target.)}
/>checked: a checkbox tells whether it is checked with e.target.checked, true or false. e.target.value is the text sent with the form, and it does not change on a click.Try it
Find the bug
Pick the line with the bug.
value={name} without onChange. The field shows name, but nobody changes name, so React puts it back to empty after every key. It also needs onChange={(e) => setName(e.target.value)}.The idea
A check next to the field
The server checks every order. But the buyer should not wait for an answer to find out they forgot the address. So the form checks by itself too, before it sends.
- When. On pressing "Place order". If there are errors, no request goes out.
- What. A function
check()returns an object with the errors per field:{ name: 'Enter your name', email: … }. An empty object means "all is fine". It is kept in the stateerrors. - Where. Next to the field, not in a common list at the top. The buyer sees which field to fix.
- For everyone. A screen reader does not see that the text is red. So the field gets
aria-invalid="true"(the reader says "invalid"), and the message gets anidthat the field points to witharia-describedby. When the focus comes to the field, the reader reads the message too.
<input id="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined}/>{errors.email && <p id="email-error" className="error">{errors.email}</p>}The form has novalidate (in JSX noValidate). Without it the browser stops the submit with its own bubbles before your code is reached, and the two checks get in each other's way.
The server checks again. The check in the browser is for the buyer's convenience. It does not protect the shop: the code of the page is with the buyer, and a request can come without it. The lesson "Untrusted input" explains why.
Try it
Check before sending
Add a check before sending. An empty name gives Enter your name, an email without @ gives The email must contain @, an empty address gives Enter a delivery address. Each message is next to its field, in an element with the id name-error, email-error and address-error, and the field has aria-invalid=true and aria-describedby pointing to it. With errors no request goes out. The check accepts the terms, orders an empty form, then with an email without @, and at last with everything right.
Console
Solution
App.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Checkout</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [problem, setProblem] = useState('');
const [errors, setErrors] = useState({});
function check() {
const found = {};
if (!name.trim()) found.name = 'Enter your name';
if (!email.includes('@')) found.email = 'The email must contain @';
if (!address.trim()) found.address = 'Enter a delivery address';
return found;
}
async function submit(e) {
e.preventDefault();
const found = check();
setErrors(found);
if (Object.keys(found).length > 0) return;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
if (res.ok) setOrderId(data.id);
else setProblem(Object.values(data.errors).join(' '));
}
if (orderId) return <p role="status">Order no. {orderId} is placed.</p>;
return (
<form onSubmit={submit} noValidate>
<p>In the cart: White bread × 2, Fresh milk × 1</p>
<label htmlFor="name">Name</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && <p id="name-error" className="error">{errors.name}</p>}
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" className="error">{errors.email}</p>}
<label htmlFor="address">Address</label>
<input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
aria-invalid={!!errors.address}
aria-describedby={errors.address ? 'address-error' : undefined}
/>
{errors.address && <p id="address-error" className="error">{errors.address}</p>}
<label htmlFor="payment">Payment</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Cash on delivery</option>
<option value="card">Card on delivery</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
I accept the terms
</label>
{problem && <p role="alert">{problem}</p>}
<button type="submit" disabled={!agree}>
Place order
</button>
</form>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Add const [errors, setErrors] = useState({}) and a function check() that returns an object with the errors. In submit first const found = check(); setErrors(found);, and if found has keys, return before fetch. Next to every field: aria-invalid={!!errors.name}, aria-describedby={errors.name ? 'name-error' : undefined} and {errors.name && <p id="name-error">…</p>}.
The check catches a message without aria-invalid and aria-describedby: on the screen it looks the same, but for a screen reader the field is fine. It also catches messages after which the request goes out anyway: then a moment later the server's answer shows up too.
The idea
Sending and the server's answer
The request takes time, and the buyer does not know whether the click got through. If they click again, a second order goes out. So the state also has sending:
- Before
fetch,sendingbecomestrue. The button isdisabled, and next to it it says "Sending…". - After the answer,
sendingbecomesfalse, whatever came back. - 201: the body is
{ id }. The form is replaced with "Order no. 61 is placed." - 400: the body is
{ errors: { name, email, address, items } }, one message per field. They go into the sameerrors, so they show in the same places next to the fields.itemshas no field: it shows next to the line with the cart.
setSending(true);const res = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email, address, payment, items: ITEMS }),});const data = await res.json();setSending(false);if (res.ok) setOrderId(data.id);else setErrors(data.errors);The check in the browser is simpler than the server's: it only looks for "@". The email maria@shop passes it, and the server sends it back with "The email is not valid". So the form must also be able to show an error it did not know about.
More precisely, if you are curious
Uncontrolled fields. A field without value keeps the text itself. The value is taken on submit: from FormData or with a ref. defaultValue gives only the starting text. It is less code, but the state does not know what the buyer types until they submit. So you cannot disable the button by the field or check while they type.
From uncontrolled to controlled. If value is undefined at the start (useState() with no starting value) and later becomes text, React says in the console A component is changing an uncontrolled input to be controlled. The starting value of a text field is '', not undefined.
Forms with an action in React 19. <form action={fn}> calls fn with the FormData of the form and then clears the uncontrolled fields. There are hooks around it too (useActionState, useFormStatus). It is another style, closer to the form from the HTML lesson. Controlled fields stay the most direct way when the interface must react while the buyer types.
One object instead of five states. You can also have const [form, setForm] = useState({ name: '', email: '', … }) with one onChange function that reads e.target.name. Then the new object is a copy: setForm({ ...form, [e.target.name]: e.target.value }).
When to check. On submit is the simplest. A check on every key shows errors before the buyer has finished ("the email is not valid" after the first letter). The common answer is: the first check on submit or on leaving the field (blur), and after that on every key, so the error disappears as soon as it is fixed.
Focus on an error. After a failed submit it helps to move the focus to the first field with an error. Then the keyboard and the screen reader get straight to the problem.
The check cannot see the code. The tasks here check behaviour: what is shown, what is disabled, what is sent. They do not see whether the check is in a separate function or how the variables are named.
Exercise
The order, all the way
Now the form checks in the browser, but it does not show that it is sending, and it puts the server's errors into one paragraph. Make it a real order: while the request runs, the Place order button is disabled and there is a text Sending… (with role=status). On a 400 answer the errors from data.errors go next to the fields, in the same places as yours (email-error and aria-invalid on the email), and the error for items next to the line with the cart. The check types the email maria@shop: it passes your check, but the server sends it back with The email is not valid. Then it adds .bg and the order goes through: Order no. 61 is placed.
Console
Solution
App.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Checkout</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [errors, setErrors] = useState({});
const [sending, setSending] = useState(false);
function check() {
const found = {};
if (!name.trim()) found.name = 'Enter your name';
if (!email.includes('@')) found.email = 'The email must contain @';
if (!address.trim()) found.address = 'Enter a delivery address';
return found;
}
async function submit(e) {
e.preventDefault();
const found = check();
setErrors(found);
if (Object.keys(found).length > 0) return;
setSending(true);
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
setSending(false);
if (res.ok) setOrderId(data.id);
else setErrors(data.errors);
}
if (orderId) return <p role="status">Order no. {orderId} is placed.</p>;
return (
<form onSubmit={submit} noValidate>
<p>In the cart: White bread × 2, Fresh milk × 1</p>
{errors.items && <p role="alert">{errors.items}</p>}
<label htmlFor="name">Name</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && <p id="name-error" className="error">{errors.name}</p>}
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" className="error">{errors.email}</p>}
<label htmlFor="address">Address</label>
<input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
aria-invalid={!!errors.address}
aria-describedby={errors.address ? 'address-error' : undefined}
/>
{errors.address && <p id="address-error" className="error">{errors.address}</p>}
<label htmlFor="payment">Payment</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Cash on delivery</option>
<option value="card">Card on delivery</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
I accept the terms
</label>
{sending && <p role="status">Sending…</p>}
<button type="submit" disabled={!agree || sending}>
Place order
</button>
</form>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Add const [sending, setSending] = useState(false). Around fetch: setSending(true) before and setSending(false) after the answer. The button is disabled={!agree || sending}, and {sending && <p role="status">Sending…</p>} stands above it. On an error, instead of the common paragraph, setErrors(data.errors): the server's messages go into the same errors and show next to the fields. The problem paragraph is no longer needed.
The check catches a button that stays enabled while it says "Sending…", and a button that never gets enabled after the answer (a forgotten false).
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: The first component · Conditions: empty, error, loading · State · Data from an API in a component
- Continues in: Shared state · The shop front
- Tasks on this topic: Effects, data and forms