React Form Validation

In the last lesson you learned about React Multiple Inputs with One Handler, so a whole form now lives in one state object. This lesson adds validation, which means checking what the user typed and showing a friendly message before the form goes through.

🤔 Why do we need validation?

Here’s the pain. Without validation, the form takes whatever the user gives it, even if it makes no sense.

  • Someone hits submit with an empty email, and you send a blank value to your server.
  • Someone types a 2-character password, and your account is barely protected.
  • The user gets no feedback, so they don’t even know what they did wrong.

So validation is about catching bad input early and telling the user in plain words. We call this client-side validation, because it runs in the browser, right next to the user.

🛡️ This is convenience only, not security

Before we write any code, one thing has to be very clear. Client-side validation is for the user’s comfort, not your security wall.

  • Browser checks are easy to skip, so a user can turn off JavaScript or send a request straight to your server.
  • Never trust the browser alone. Your server must check the same data again before saving it.
  • Think of the browser check as a helpful early warning, and the server check as the real guard.

Always validate on the server too

Client-side validation makes the form nice to use. It does NOT make it safe. Anyone can bypass the browser and send raw data to your backend. Always re-check every field on the server before you store or trust it.

❌ What happens with no validation

Let’s first see the problem with our own eyes. This signup form takes an email and a password, but it never checks them, so bad data just sails through.

import { useState } from "react";
function SignupForm() {
const [form, setForm] = useState({ email: "", password: "" });
function handleChange(e) {
setForm({ ...form, [e.target.name]: e.target.value });
}
function handleSubmit(e) {
e.preventDefault();
// ❌ no checks at all - empty email and a 2-char password get through
console.log("Sending to server:", form);
}
return (
<form onSubmit={handleSubmit}>
<input name="email" value={form.email} onChange={handleChange} />
<input name="password" value={form.password} onChange={handleChange} />
<button type="submit">Sign up</button>
</form>
);
}

Walk through what goes wrong here.

  • The user can leave both boxes empty and still click Sign up.
  • handleSubmit runs and sends the empty form straight on, no questions asked.
  • There’s no message anywhere, so the user has no idea anything is wrong.

Output

(user clicks Sign up with empty fields)
Sending to server: { email: "", password: "" }

📦 Keep an errors object in state

The fix starts with a place to store the problems. We use a second piece of state, an errors object, where each key matches a field name.

  • One key per field, like email and password.
  • The value is the message we want to show, like "Email is required".
  • If a field has no problem, it simply has no key. An empty errors object means the form is clean.

Here’s the shape of it, sitting right next to the form state.

import { useState } from "react";
function SignupForm() {
const [form, setForm] = useState({ email: "", password: "" });
const [errors, setErrors] = useState({}); // starts empty: no errors yet
// ...
}

So form holds what the user typed, and errors holds what’s wrong with it. Two separate jobs, two separate pieces of state.

🔍 Write a validate function

Now the heart of it. We write one function that looks at the form and returns an errors object. It checks each field and adds a message when something fails.

function validate(values) {
const newErrors = {};
// email: must not be empty, and must look like an email
if (!values.email) {
newErrors.email = "Email is required";
} else if (!values.email.includes("@")) {
newErrors.email = "Email must contain @";
}
// password: must not be empty, and must be at least 6 characters
if (!values.password) {
newErrors.password = "Password is required";
} else if (values.password.length < 6) {
newErrors.password = "Password must be at least 6 characters";
}
return newErrors;
}

Read it from the top.

  • It starts with an empty newErrors object and only adds keys when a check fails.
  • For email, an empty value gives "Email is required", and a value with no @ gives "Email must contain @".
  • For password, an empty value gives "Password is required", and anything shorter than 6 characters gives "Password must be at least 6 characters".
  • At the end it returns the object, and if nothing failed the object is empty.

So this one validate function is your single source of truth. It takes the values in and hands the problems back out.

The @ check is on purpose simple

Checking that an email just contains @ is a very basic test. Real apps use stricter rules, but a simple contains-@ check is perfect for learning, and it already catches the most common mistake.

🚦 Run validation on submit

Now we connect the validate function to the submit. When the user clicks Sign up, we check first, then decide whether to go on.

function handleSubmit(e) {
e.preventDefault();
const newErrors = validate(form);
setErrors(newErrors);
// ✅ only send if there are no errors
if (Object.keys(newErrors).length === 0) {
console.log("Sending to server:", form);
} else {
console.log("Form has errors, not sending");
}
}

Here’s the flow each time submit runs.

  • We call validate(form) and save what comes back into the errors state.
  • Object.keys(newErrors).length tells us how many problems there are.
  • If that count is 0, the form is clean, so we send it on.
  • If there are any problems, we stop right there and the messages stay on screen.

So submit is now a gate. Nothing gets through until validation passes.

💬 Show error messages under each field

Storing errors is no good if the user can’t see them. So under each input we show its message, but only when that field actually has one.

<input name="email" value={form.email} onChange={handleChange} />
{errors.email && <p style={{ color: "red" }}>{errors.email}</p>}
<input name="password" value={form.password} onChange={handleChange} />
{errors.password && <p style={{ color: "red" }}>{errors.password}</p>}

This is the same conditional rendering you’ve seen before.

  • errors.email && ... means “if there’s an email error, show this paragraph”.
  • When there’s no error, errors.email is undefined, so React renders nothing there.
  • The message sits right under its own input, so the user knows exactly which box to fix.

So each field shows its own problem in its own place. That’s what conditional rendering gives us here.

💡 Putting it all together

Now let’s join every piece into one small working signup form. It checks on submit, blocks bad data, and shows a message under each field.

import { useState } from "react";
function SignupForm() {
const [form, setForm] = useState({ email: "", password: "" });
const [errors, setErrors] = useState({});
function handleChange(e) {
setForm({ ...form, [e.target.name]: e.target.value });
}
function validate(values) {
const newErrors = {};
if (!values.email) newErrors.email = "Email is required";
else if (!values.email.includes("@")) newErrors.email = "Email must contain @";
if (!values.password) newErrors.password = "Password is required";
else if (values.password.length < 6)
newErrors.password = "Password must be at least 6 characters";
return newErrors;
}
function handleSubmit(e) {
e.preventDefault();
const newErrors = validate(form);
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) {
console.log("Sending to server:", form); // server still re-checks this!
}
}
return (
<form onSubmit={handleSubmit}>
<input name="email" value={form.email} onChange={handleChange} />
{errors.email && <p style={{ color: "red" }}>{errors.email}</p>}
<input name="password" value={form.password} onChange={handleChange} />
{errors.password && <p style={{ color: "red" }}>{errors.password}</p>}
<button type="submit">Sign up</button>
</form>
);
}

Here’s the whole story in order.

  • The user types, and handleChange keeps the form state up to date with one handler.
  • On submit, validate checks both fields and we save the result into errors.
  • Any field with a problem shows its red message right underneath.
  • Only when errors is empty do we send the data on, and even then the server checks it again.

Output

(user submits an empty form)
Email is required
Password is required
(user types "alex" and "123" then submits)
Email must contain @
Password must be at least 6 characters
(user types valid values then submits)
Sending to server: { email: "...", password: "..." }

🧱 Optionally check on change or blur

Checking only on submit works fine. But sometimes you want faster feedback while the user is still typing or when they leave a field.

  • On change means you re-run validation on every keystroke, so the message updates live.
  • On blur means you check when the user clicks out of a field, which feels less noisy.
  • A common nice pattern is to wait for submit the first time, then switch to live checks after.

Here’s the small change for checking as the user types.

function handleChange(e) {
const next = { ...form, [e.target.name]: e.target.value };
setForm(next);
setErrors(validate(next)); // re-check the whole form on every keystroke
}

Notice what we did here.

  • We build the next form first, so we validate the up-to-date values, not the old ones.
  • Then we run validate(next) and refresh the errors right away.
  • Now messages appear and disappear live as the user fixes things.

Blur is gentler than change

Checking on every keystroke can feel pushy, because errors flash while the user is still typing. Checking on blur, when they leave the field, is calmer. Many forms use blur for a smoother feel.

🔒 Block or disable submit on errors

The last piece is making sure bad data can’t get through. You have two simple ways to do this, and they work well together.

  • Block it inside the handler. We already do this with the Object.keys(newErrors).length === 0 check.
  • Disable the button. If you validate live, you can turn the button off while errors exist.

Here’s how you disable the button based on the errors object.

<button type="submit" disabled={Object.keys(errors).length > 0}>
Sign up
</button>

Quick note on both approaches.

  • The disabled button is a nice visual hint, so the user sees they can’t submit yet.
  • But never rely on the disabled button alone, because it only works if you validate live.
  • The real gate is the check inside handleSubmit. That’s what truly blocks submit no matter what.

Disabled alone is not enough

A disabled button is a hint, not a lock. If you only validate on submit, the button isn’t disabled yet, so the in-handler check is what actually stops bad data. Keep the check in handleSubmit either way.

🧩 What You’ve Learned

  • ✅ Client-side validation is a convenience for the user, and the server must still re-check everything for security
  • ✅ Keep an errors object in state, with one key per field holding its message
  • ✅ A validate function takes the values and returns an errors object, empty when all is well
  • ✅ Common checks are required (not empty), a minimum length, and a simple email contains-@ test
  • ✅ On submit, run validate, save the result, and only send when the errors object is empty
  • ✅ Show each field’s message conditionally right under its input
  • ✅ You can also validate on change or blur for live feedback, and disable the button as a hint

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    Why is client-side validation not enough on its own?

    Why: Browser checks are easy to skip, for example by sending a request straight to the server. Client-side validation is for user comfort. The server must validate again for real security.

  2. 2

    What should a validate function return?

    Why: The pattern is to return an errors object. Each failing field gets a key with a message. An empty object means there are no errors and the form can be submitted.

  3. 3

    On submit, how do you check whether the form is clean before sending?

    Why: An empty errors object means no problems. Object.keys(errors).length === 0 tells you there are zero keys, so the form passed and you can send it.

  4. 4

    What is the safest way to stop bad data from being submitted?

    Why: A disabled button is just a hint and only helps if you validate live. The real gate is the check inside handleSubmit that refuses to send when there are errors.

🚀 What’s Next?

That wraps up the React Forms module. You can now build a form, hold its values, validate them, and only let clean data through. Next we move into Hooks, where you’ll really understand the tools like useState you’ve been using all along, and meet new ones that unlock even more.

React Hooks

Share & Connect