Skip to content
wannacode

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.

React

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
  1. The form keeps the name in state: name = ''. The field on the page shows what the state says, and it says: empty.
  2. The buyer presses M. The browser puts the letter in the field and fires the input event.
  3. onChange catches the event. e.target.value is the text of the field now: “M”.
  4. setName("M") changes the state. That is the only place where the name lives.
  5. The state changed, so React calls CheckoutForm again. It returns the field with value = name: “M”.
  6. The field gets its value from the state. It is the same letter, but now the state decides what the field shows.
  7. Every key goes around the whole circle: field, event, state, new render, field. One source of truth: the state.
  8. Now the same field without value and onChange. The field works, but nobody listens to it and nobody gives it a value.
  9. The buyer types “Maria”. The letters stay in the DOM. The state still says: name = ''.
  10. On submit the code sends the state: { name: '' }. The server answers “Name is required”, while the field shows “Maria”.
  11. 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'.

React

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.)}
/>

Try it

Find the bug

The buyer presses keys in the field "Name", and nothing appears in it. The email works. Which line is to blame?

Pick the line with the bug.

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.

  1. When. On pressing "Place order". If there are errors, no request goes out.
  2. 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 state errors.
  3. Where. Next to the field, not in a common list at the top. The buyer sees which field to fix.
  4. 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 an id that the field points to with aria-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.

React

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:

  1. Before fetch, sending becomes true. The button is disabled, and next to it it says "Sending…".
  2. After the answer, sending becomes false, whatever came back.
  3. 201: the body is { id }. The form is replaced with "Order no. 61 is placed."
  4. 400: the body is { errors: { name, email, address, items } }, one message per field. They go into the same errors, so they show in the same places next to the fields. items has 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.

React

Test

Check yourself

1. The field "Name" shows "Maria", and the state name is empty. Which one goes to the server?
2. The field is <input value={name} />, with no onChange. What happens when the buyer types?
3. How do you read a checkbox in onChange?
4. The check in the browser says the email is fine. Should the server check it again?
5. Why is the "Place order" button disabled while the request is running?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·