In lesson 9 you built forms with controlled inputs, where every key press goes through state. That is great to learn the idea, but it is not the only way, and for big forms it can be too much code. In this lesson you will learn the shorter way with FormData, how to write clean validation, and how a form library can help.
The browser already knows your form
A normal HTML form already keeps the values of its fields. When the person submits, the browser can hand you all values at once with FormData. You do not need state for every field.
function ContactForm() {
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = Object.fromEntries(formData);
console.log(data); // { name: '...', email: '...', message: '...' }
}
return (
<form onSubmit={handleSubmit}>
<label>Name <input name="name" required /></label>
<label>Email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" /></label>
<button type="submit">Send</button>
</form>
);
}
- Every input needs a `name`. That name becomes the key in the data.
event.currentTargetis the form itself.Object.fromEntries(formData)turns the form data into a plain object.- There is no state at all, and no redraw while the person types.
Checkboxes with the same name give several values. Use formData.getAll('hobby') to read them as an array. A file input gives a File object, so you can send the whole formData to a server with fetch(url, { method: 'POST', body: formData }). Do not set the Content-Type header yourself in that case, because the browser must add the right boundary.
Use the browser's own validation first
HTML gives you free checks: required, type="email", minLength, maxLength, min, max and pattern. The browser shows a message and stops the submit. This is a good first layer, and it works without any JavaScript.
<input name="age" type="number" min="18" max="99" required />
<input name="code" pattern="[0-9]{6}" title="Six digits" required />
The built-in bubbles look different in each browser, and you cannot style them easily. When you need your own messages, add noValidate to the form and write your own checks.
Writing your own validation function
Put the rules in one plain function that takes the data and returns an object of errors. It is easy to read and easy to test, and it does not depend on React.
export function validateSignUp(data) {
const errors = {};
if (!data.name || data.name.trim().length < 2) {
errors.name = 'Please enter your name (at least 2 letters).';
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email || '')) {
errors.email = 'Please enter a valid email address.';
}
if (!data.password || data.password.length < 8) {
errors.password = 'Your password needs at least 8 characters.';
}
return errors;
}
import { useState } from 'react';
import { validateSignUp } from './validate';
function SignUpForm() {
const [errors, setErrors] = useState({});
const [done, setDone] = useState(false);
function handleSubmit(event) {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget));
const found = validateSignUp(data);
setErrors(found);
if (Object.keys(found).length > 0) return;
setDone(true); // here you would send data to the server
}
if (done) return <p role="status">Thank you! Your account is ready.</p>;
return (
<form onSubmit={handleSubmit} noValidate>
<Field label="Name" name="name" error={errors.name} />
<Field label="Email" name="email" type="email" error={errors.email} />
<Field label="Password" name="password" type="password" error={errors.password} />
<button type="submit">Create account</button>
</form>
);
}
function Field({ label, name, type = 'text', error }) {
const errorId = name + '-error';
return (
<div>
<label htmlFor={name}>{label}</label>
<input id={name} name={name} type={type}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? errorId : undefined} />
{error && <p id={errorId} role="alert">{error}</p>}
</div>
);
}
The Field component keeps the label, the input and the error together, so every field is accessible without extra work. You wrote the rules once in validateSignUp, and you can test it with plain calls.
When to show errors
- On submit: the simplest. Show all errors together, and move focus to the first wrong field.
- On blur (when the person leaves a field): friendlier for long forms. Do not show an error while the person is still typing their first attempt.
- Clear the error as soon as the value becomes valid, so people see they fixed it.
Write messages that tell people what to do: “Please enter a valid email address” is better than “Invalid input”. Do not blame the person. And never rely only on colour, since some people cannot tell red from green: always include text.
Server validation always
const response = await fetch('/api/signup', { method: 'POST', body: formData });
if (response.status === 409) {
setErrors({ email: 'This email is already registered. Try signing in.' });
return;
}
React Hook Form: when a library helps
For large forms with many rules, a library saves time. React Hook Form is popular because it uses uncontrolled inputs, so the form does not redraw on every key press. You register fields and get errors from it:
npm install react-hook-form
import { useForm } from 'react-hook-form';
function LoginForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();
async function onSubmit(data) {
await fakeLogin(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Email</label>
<input id="email" type="email"
{...register('email', { required: 'Email is required' })} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<label htmlFor="password">Password</label>
<input id="password" type="password"
{...register('password', { required: 'Password is required', minLength: { value: 8, message: 'At least 8 characters' } })} />
{errors.password && <p role="alert">{errors.password.message}</p>}
<button disabled={isSubmitting}>Log in</button>
</form>
);
}
register('email', rules) connects an input to the form and gives it its rules. handleSubmit runs your function only when all rules pass. isSubmitting is true while your async function runs, which is handy for disabling the button. For complex rules, you can combine it with a schema library such as Zod. Start with the plain way in this lesson, and add a library when your forms become big.
Good form habits
- Every input has a visible
label. A placeholder disappears when typing, so it is not a label. - Use the right
typeandautoCompletevalues (email,tel,current-password,one-time-code). Phones then show the right keyboard and can fill in details. - Disable the submit button while sending, and show progress.
- Do not clear a long form when an error happens. Keep what people typed.
- Keep forms short. Every extra field loses some people.
Common mistakes
- Forgetting
nameon inputs, soFormDatais empty. - Setting the
Content-Typeheader when sendingFormData. - Checking only in the browser and not on the server.
- Showing errors only in red with no text.
- Using
valuewithoutonChange, or mixing controlled and uncontrolled inputs in the same field.
Choosing your approach: a short guide
You now know three ways to build a form. Here is how to pick between them for a real project.
- Controlled inputs with state: best when the screen must react while people type, for example a live character counter, instant formatting or a search that filters as you type.
- `FormData` with a validation function: best for most normal forms, such as contact, sign-up and settings. Short code, no state for each field, and an easy function to test.
- React Hook Form: best for large forms with many rules, many steps or dynamic fields, where you want less code and fewer redraws.
Whatever you choose, the same good habits apply: clear labels, helpful messages, the right input types, a disabled button while sending, and a second check on the server. A form that is easy to use is worth more than a clever one.
Questions beginners often ask
Can I use FormData with a controlled input? You can, but it mixes two ways. Pick one way for each form so the code stays easy to understand.
How do I validate on blur? Add onBlur to the field and run the checks for that one field, then set only that field error. Clear it when the value becomes valid.
How do I reset a form after submit? For an uncontrolled form, call event.currentTarget.reset(). For a controlled form, set the state back to the starting object. In React 19 a form action resets the form for you after it finishes.
What is Zod? A small library for describing the shape of data and checking it, with clear errors. You can use the same rules in the browser and on a Node server.
Your task
- Build the sign-up form with
FormData,noValidateand thevalidateSignUpfunction. Add a confirm password field to the rules. - Move focus to the first field with an error after a failed submit (use a ref or
querySelector). - Show an error from a fake server response, such as “email already used”.
- Rebuild the login form with React Hook Form and compare the code.
How to check it worked
- Every error appears next to its field, and disappears when fixed.
- A keyboard user can reach and fix every field.
- You can read the form values without any
useStatefor fields.
Summary
FormDatareads all fields of a form on submit. Give every input aname.- Use HTML validation as a first layer, and write one plain validation function for your own messages.
- Show clear, helpful text errors next to fields and connect them with
aria-describedby. - Always validate again on the server. Use React Hook Form when forms get large.