Programming Beginner 11 min

How to Build a Controlled Form with Validation in React

Controlled forms — where React owns every input value — are the standard pattern in React. The trade-off is more code compared to uncontrolled inputs, but you get instant validation feedback, easy cross-field logic, and predictable submit behavior. This guide builds a complete form with field-level errors, a disabled submit button during submission, and a clean reset after success. At the end, you will know exactly when it makes sense to reach for react-hook-form instead.

Step-by-step

  1. 1

    Choose: one state object vs separate useState

    For a form with more than 2–3 fields, store all field values in a single state object. It makes the reset trivial (one setState call), and the onChange handler is reusable across all fields. Separate useState calls for each field get unwieldy fast and scatter your reset logic.

    jsx
    const initialValues = {
      name:     '',
      email:    '',
      password: '',
      role:     'user',
    };
    
    function SignupForm() {
      const [formData, setFormData] = useState(initialValues);
    
      function handleChange(e) {
        const { name, value } = e.target;
        setFormData(prev => ({ ...prev, [name]: value }));
      }
    
      // All inputs share this one handler via the `name` attribute
    }
  2. 2

    Wire up controlled inputs

    A controlled input has both value (from state) and onChange (updates state). Without value, the input is uncontrolled and React cannot track what is in it. Without onChange, the input is read-only and the user cannot type.

    The name attribute must match the key in formData — that is how the single handler knows which field to update.

    jsx
    return (
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="name">Name</label>
          <input
            id="name"
            name="name"
            type="text"
            value={formData.name}
            onChange={handleChange}
          />
        </div>
    
        <div>
          <label htmlFor="email">Email</label>
          <input
            id="email"
            name="email"
            type="email"
            value={formData.email}
            onChange={handleChange}
          />
        </div>
    
        <div>
          <label htmlFor="password">Password</label>
          <input
            id="password"
            name="password"
            type="password"
            value={formData.password}
            onChange={handleChange}
          />
        </div>
    
        <button type="submit">Sign up</button>
      </form>
    );
  3. 3

    Write the validation function

    Keep validation logic in a pure function that takes the form values and returns an errors object. This makes it easy to unit-test independently of the component. Return an empty object when the form is valid.

    javascript
    function validate(values) {
      const errors = {};
    
      if (!values.name.trim()) {
        errors.name = 'Name is required.';
      }
    
      if (!values.email.trim()) {
        errors.email = 'Email is required.';
      } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
        errors.email = 'Enter a valid email address.';
      }
    
      if (!values.password) {
        errors.password = 'Password is required.';
      } else if (values.password.length < 8) {
        errors.password = 'Password must be at least 8 characters.';
      }
    
      return errors; // empty object = valid
    }
  4. 4

    Validate on blur, show errors inline

    Validate on blur (when a field loses focus), not on every keystroke. Showing errors while the user is mid-typing is annoying. On submit, validate all fields at once regardless of touch state.

    Store errors in state and display them immediately below each field. Associate them with the input via aria-describedby for accessibility.

    jsx
    const [errors,  setErrors]  = useState({});
    const [touched, setTouched] = useState({});
    
    function handleBlur(e) {
      const { name } = e.target;
      setTouched(prev => ({ ...prev, [name]: true }));
    
      // Validate only the touched field
      const fieldErrors = validate(formData);
      setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }));
    }
    
    // In JSX, below each input:
    // {touched.email && errors.email && (
    //   <span id="email-error" role="alert">{errors.email}</span>
    // )}
    //
    // On the input itself:
    // aria-describedby={errors.email ? 'email-error' : undefined}
    // aria-invalid={!!errors.email}
  5. 5

    Handle submit with full validation

    On submit, validate every field (ignoring the touched state — the user clicked submit, show all errors). If there are any errors, focus the first invalid field and bail out. Otherwise, proceed with the API call.

    jsx
    const [submitting, setSubmitting] = useState(false);
    
    async function handleSubmit(e) {
      e.preventDefault();
    
      const fieldErrors = validate(formData);
      if (Object.keys(fieldErrors).length > 0) {
        setErrors(fieldErrors);
        // Mark all fields as touched so errors are visible
        setTouched({ name: true, email: true, password: true, role: true });
        return;
      }
    
      setSubmitting(true);
      try {
        await api.signup(formData);
        setFormData(initialValues);   // clear form on success
        setErrors({});
        setTouched({});
        // navigate or show success message
      } catch (err) {
        setErrors({ form: err.message }); // server-level error
      } finally {
        setSubmitting(false);
      }
    }
  6. 6

    Disable the submit button while submitting

    Prevent double-submits by disabling the button while the request is in flight. Change the label to give the user feedback that something is happening. Re-enable on success or error.

    jsx
    <button
      type="submit"
      disabled={submitting}
      aria-busy={submitting}
    >
      {submitting ? 'Creating account...' : 'Sign up'}
    </button>
    
    {/* Show server-level errors above the button */}
    {errors.form && (
      <p role="alert" style={{ color: 'red' }}>{errors.form}</p>
    )}
  7. 7

    Put it all together

    Here is the complete component. Notice how the single handleChange serves all inputs, the validation function is separate and testable, and the submit handler covers the full happy path and error path.

    jsx
    import { useState } from 'react';
    
    const initialValues = { name: '', email: '', password: '' };
    
    function validate(v) {
      const e = {};
      if (!v.name.trim())   e.name     = 'Name is required.';
      if (!v.email.trim())  e.email    = 'Email is required.';
      else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email))
                            e.email    = 'Enter a valid email.';
      if (!v.password)      e.password = 'Password is required.';
      else if (v.password.length < 8)
                            e.password = 'Min 8 characters.';
      return e;
    }
    
    export function SignupForm() {
      const [formData,   setFormData]   = useState(initialValues);
      const [errors,     setErrors]     = useState({});
      const [touched,    setTouched]    = useState({});
      const [submitting, setSubmitting] = useState(false);
      const [success,    setSuccess]    = useState(false);
    
      const handleChange = e => {
        const { name, value } = e.target;
        setFormData(p => ({ ...p, [name]: value }));
      };
    
      const handleBlur = e => {
        const { name } = e.target;
        setTouched(p => ({ ...p, [name]: true }));
        setErrors(p => ({ ...p, [name]: validate(formData)[name] }));
      };
    
      const handleSubmit = async e => {
        e.preventDefault();
        const errs = validate(formData);
        if (Object.keys(errs).length) {
          setErrors(errs);
          setTouched({ name: true, email: true, password: true });
          return;
        }
        setSubmitting(true);
        try {
          await fakeApi(formData);
          setFormData(initialValues);
          setErrors({});
          setTouched({});
          setSuccess(true);
        } catch (err) {
          setErrors({ form: err.message });
        } finally {
          setSubmitting(false);
        }
      };
    
      if (success) return <p>Account created!</p>;
    
      return (
        <form onSubmit={handleSubmit} noValidate>
          {['name', 'email', 'password'].map(field => (
            <div key={field}>
              <label htmlFor={field}>{field}</label>
              <input
                id={field} name={field}
                type={field === 'password' ? 'password' : field === 'email' ? 'email' : 'text'}
                value={formData[field]}
                onChange={handleChange}
                onBlur={handleBlur}
                aria-invalid={!!(touched[field] && errors[field])}
                aria-describedby={errors[field] ? `${field}-err` : undefined}
              />
              {touched[field] && errors[field] && (
                <span id={`${field}-err`} role="alert">{errors[field]}</span>
              )}
            </div>
          ))}
          {errors.form && <p role="alert">{errors.form}</p>}
          <button type="submit" disabled={submitting}>
            {submitting ? 'Saving...' : 'Sign up'}
          </button>
        </form>
      );
    }
  8. 8

    Graduate to react-hook-form when needed

    The controlled pattern above is entirely sufficient for forms with up to ~8 fields and simple validation rules. When you need to go further — deeply nested field arrays, complex cross-field validation, file uploads, multi-step forms, or performance-sensitive forms with many fields — react-hook-form is the standard choice. It uses uncontrolled inputs internally (no re-render on every keystroke), integrates with schema libraries like Zod or Yup, and has a much smaller API surface than alternatives like Formik.

    jsx
    // react-hook-form version of the same form
    import { useForm } from 'react-hook-form';
    
    function SignupForm() {
      const {
        register,
        handleSubmit,
        formState: { errors, isSubmitting },
        reset,
      } = useForm();
    
      const onSubmit = async (data) => {
        await fakeApi(data);
        reset();
      };
    
      return (
        <form onSubmit={handleSubmit(onSubmit)} noValidate>
          <input
            type="text"
            {...register('name', { required: 'Name is required.' })}
            aria-invalid={!!errors.name}
          />
          {errors.name && <span role="alert">{errors.name.message}</span>}
    
          <input
            type="email"
            {...register('email', {
              required: 'Email is required.',
              pattern:  { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Invalid email.' },
            })}
            aria-invalid={!!errors.email}
          />
          {errors.email && <span role="alert">{errors.email.message}</span>}
    
          <button type="submit" disabled={isSubmitting}>
            {isSubmitting ? 'Saving...' : 'Sign up'}
          </button>
        </form>
      );
    }

Tips & gotchas

  • Always add `noValidate` to the `<form>` element when doing custom validation. Otherwise the browser's built-in validation UI fights with your own error messages.
  • The `name` attribute on each input is not optional — it is the key that ties the generic `handleChange` to the correct field in `formData`.
  • For checkboxes, use `e.target.checked` instead of `e.target.value`. For multi-select, use `Array.from(e.target.selectedOptions, o => o.value)`.
  • Validate on the server too. Client-side validation is a UX improvement, not a security measure — anyone can bypass it.
  • When using `react-hook-form` with a UI library (MUI, Chakra), use the `Controller` wrapper for inputs that do not expose a native `ref`.

Wrapping up

A controlled React form is a handful of moving pieces — one state object for values, one for errors, one for touched fields, one for submitting status — but each piece has a single, clear job. Master this pattern and you will handle 90% of forms in real applications. When forms grow beyond that — field arrays, wizard flows, complex dependencies — react-hook-form handles those cases efficiently without the controlled-input overhead.

#React #Forms #Validation
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.