React Form Validation
Table of Contents + −
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.
handleSubmitruns 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
emailandpassword. - 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
newErrorsobject 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 theerrorsstate. Object.keys(newErrors).lengthtells 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.emailis 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
handleChangekeeps the form state up to date with one handler. - On submit,
validatechecks both fields and we save the result intoerrors. - Any field with a problem shows its red message right underneath.
- Only when
errorsis empty do we send the data on, and even then the server checks it again.
Output
(user submits an empty form)Email is requiredPassword 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
nextform 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 === 0check. - 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
disabledbutton 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
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
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
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
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.