intermediate30 min

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")); // null

Try 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.

Loading editor…

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.

Loading editor…

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.

Loading editor…

Stuck? Get a hint.

Guided local lab

Build a Validated Signup Form Locally

Runs on your computer
This lab runs on your own computer, in your own terminal and editor — not in your browser. VisaSparkSchools does not execute, run, or verify these commands for you. Follow the verification steps yourself to confirm your result.

Set 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

  1. Run `npm create vite@latest signup-form -- --template react` in a terminal.
  2. Run `cd signup-form && npm install`.
  3. Replace the contents of `src/App.jsx` with the starter file below.
  4. Run `npm run dev` and open the printed local URL in your browser.

Project structure

signup-form/
  src/
    App.jsx
    main.jsx
  package.json

Starter 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 dev
  • Build 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 field

    Expected: An 'Email is required.' message appears beneath the email field

  • Type a valid email and a password shorter than 8 characters, then click Sign up

    Expected: 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 up

    Expected: 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 displayedConfirm 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 errorDelete 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 inputCheck 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

Knowledge check

1. What makes an input 'controlled' in React?
2. Why is 'Invalid input' considered weak validation feedback compared to 'Password must be at least 8 characters'?
3. Why does this lesson use a guided local lab instead of a browser Run button for the actual form component?

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.