Controlled Forms and Validation
A controlled input keeps React's state as the single source of truth for form data. Build a real, validated signup form locally, with a genuine React project running on your own machine.
What you'll learn
- Explain what makes an input 'controlled' versus uncontrolled
- Design a validation strategy that gives useful, specific feedback
- Build and run a real controlled form component using Vite and React on your own machine
Prerequisites
Explanation
An HTML <input> normally keeps its own internal value, independent of your JavaScript — you only find out what's in it when you ask (e.g. on submit). A controlled input inverts that: its value is set from React state, and every keystroke updates that state via onChange, so React state is always the single, authoritative source of truth for what the input currently holds — never the DOM element itself.
const [email, setEmail] = useState("");
<input value={email} onChange={(e) => setEmail(e.target.value)} />
This costs a re-render per keystroke, which sounds expensive but isn't in practice for ordinary forms — and it buys you something valuable: the value is available everywhere in the component (for live validation, for a character counter, for enabling/disabling the submit button) without ever reaching into the DOM to ask for it.
Good validation feedback is specific, not just present. "Invalid input" tells a user nothing actionable. "Password must be at least 8 characters" tells them exactly what to fix. Deciding when to validate matters too: validating on every keystroke from the first character typed produces an aggressively red, discouraging form before the user has even finished typing; a common, learner-friendly middle ground is validating on blur (when the user leaves the field) for the first pass, then live on every keystroke once an error has already been shown for that field — so corrections get immediate positive feedback, but a field isn't judged before the user's even done with it.
This lesson's guided local lab is where the real component work happens: you'll set up a small Vite + React project on your own machine and build a genuinely controlled, validated form — nothing here in the browser can substitute for actually running JSX through a real build tool.
Example
The validation LOGIC behind a controlled form field, kept separate from React so it's independently testable — exactly the function the guided local lab's real component will call.
function validatePassword(password) {
if (password.length === 0) return "Password is required.";
if (password.length < 8) return "Password must be at least 8 characters.";
if (!/\d/.test(password)) return "Password must include at least one digit.";
return null; // null means valid
}
console.log(validatePassword("")); // "Password is required."
console.log(validatePassword("short1")); // "Password must be at least 8 characters."
console.log(validatePassword("longenough")); // "Password must include at least one digit."
console.log(validatePassword("longenough1")); // nullTry it yourself
Add a rule requiring at least one uppercase letter, then test it against a password missing one.
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
Write validateEmail(email) returning a specific error string, or null if valid. Rules: required (non-empty), must contain exactly one '@', and must have at least one character after the last '.'.
Checks: requires a non-empty email · rejects a missing @ symbol · rejects more than one @ symbol · accepts a well-formed email
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
Write validateForm(values) that takes an object like { email, password } and returns an object mapping each invalid field name to its error message, omitting any field that's valid. Reuse the pattern from the example (empty errors object means the whole form is valid).
Checks: reports both fields when both are invalid · returns an empty object when the form is fully valid · omits valid fields from the errors object
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Guided local lab
Build a Validated Signup Form Locally
Runs on your computerSet up a real Vite + React project on your own machine and build a controlled signup form (name, email, password) with live validation, reusing the validateEmail/validatePassword logic style from this lesson's browser exercises inside a real component.
Required tools
- Node.js (20.x LTS or newer)
- npm (10.x (bundled with Node.js))
Setup
- Run `npm create vite@latest signup-form -- --template react` in a terminal.
- Run `cd signup-form && npm install`.
- Replace the contents of `src/App.jsx` with the starter file below.
- Run `npm run dev` and open the printed local URL in your browser.
Project structure
signup-form/
src/
App.jsx
main.jsx
package.jsonStarter files
src/App.jsx
import { useState } from "react";
function validateEmail(email) {
if (email.length === 0) return "Email is required.";
if (!email.includes("@")) return "Email must contain an @ symbol.";
return null;
}
function validatePassword(password) {
if (password.length === 0) return "Password is required.";
if (password.length < 8) return "Password must be at least 8 characters.";
return null;
}
export default function App() {
const [values, setValues] = useState({ name: "", email: "", password: "" });
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
function handleChange(field) {
return (e) => {
const next = { ...values, [field]: e.target.value };
setValues(next);
if (touched[field]) {
validateField(field, next[field]);
}
};
}
function validateField(field, value) {
// TODO: call validateEmail/validatePassword for the right field,
// update the errors state for just this field.
}
function handleBlur(field) {
return () => {
setTouched({ ...touched, [field]: true });
validateField(field, values[field]);
};
}
function handleSubmit(e) {
e.preventDefault();
// TODO: validate every field, and only proceed if there are no errors.
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input value={values.name} onChange={handleChange("name")} onBlur={handleBlur("name")} />
</label>
<label>
Email
<input value={values.email} onChange={handleChange("email")} onBlur={handleBlur("email")} />
{errors.email && <p role="alert">{errors.email}</p>}
</label>
<label>
Password
<input
type="password"
value={values.password}
onChange={handleChange("password")}
onBlur={handleBlur("password")}
/>
{errors.password && <p role="alert">{errors.password}</p>}
</label>
<button type="submit">Sign up</button>
</form>
);
}Requirements
- Every input is controlled (its value comes from state, never read directly from the DOM)
- Each field validates on blur the first time, then live on every keystroke once it has shown an error
- The submit handler validates all fields and does not proceed if any field is invalid
- Error messages are specific (state what's wrong), not a generic 'invalid' message
Commands to run
Start the dev server
npm run devBuild for production (sanity check only, not required to complete the lab)
npm run build
Expected behavior
Typing in the email field and leaving it (blur) with an invalid value shows a specific error message beneath the field. Fixing the value and typing further updates the error live. Submitting with any invalid field does not proceed and shows all relevant errors.
Verify it yourself
In the running app, leave the email field empty and click into the password fieldExpected: An 'Email is required.' message appears beneath the email field
Type a valid email and a password shorter than 8 characters, then click Sign upExpected: The form does not submit, and the password field shows its specific length error
Fill in a valid name, email, and an 8+ character password with a digit, then click Sign upExpected: No error messages are shown (you can confirm submission by adding a temporary console.log inside handleSubmit)
Troubleshooting
- Typing in a field doesn't update what's displayed — Confirm the input's value prop is bound to state and onChange calls setValues — an uncontrolled input (no value prop bound to state) won't reflect keystrokes back through React.
- `npm run dev` fails immediately with a module error — Delete the node_modules folder and package-lock.json, then run npm install again — a partial or interrupted install is the most common cause.
- Errors never appear even with invalid input — Check that validateField is actually being called from both handleBlur and handleChange (once touched), and that it calls setErrors with the new message.
Stuck? Get a hint.
Extension challenge
Add a fourth field, 'confirmPassword', that must match 'password' exactly, with its own specific error message when it doesn't.
When you've verified this locally, use the "Mark lesson complete" button below to record your progress.
Common mistakes
- Setting an input's initial value with `defaultValue` instead of `value`, accidentally making it uncontrolled while still trying to read/write it through state elsewhere.
- Validating every field on every keystroke from the very first character, producing an aggressively red, discouraging form before the user has finished typing anything.
- Showing a generic 'invalid' message instead of stating specifically what's wrong and how to fix it.
Knowledge check
Takeaway
Controlled inputs keep React state as the single source of truth for form data, and good validation gives specific, timed-appropriately feedback — this lesson's real component work happens on your own machine, in a real Vite + React project.
Summary
This lesson covered controlled inputs and validation-message design through browser exercises, then built a genuinely controlled, validated signup form in a real local React project via the guided local lab.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.