Skip to content
Codeloom
React

React Form Handling: Controlled, Uncontrolled, and Libraries

Master React forms with controlled and uncontrolled inputs, validation patterns, and React Hook Form. Build accessible, performant forms step by step.

·7 min read · By Codeloom
Intermediate 15 min read

What you'll learn

  • Controlled vs uncontrolled form patterns
  • Building validation from scratch
  • Using React Hook Form for complex forms
  • Accessible form patterns and error handling

Prerequisites

  • React hooks (useState, useRef)
  • HTML form elements

Forms are where React apps get complex fast. A simple login form is easy. A multi-step registration form with validation, conditional fields, and error handling takes real planning. This guide walks you through every approach: controlled inputs, uncontrolled inputs, and form libraries.

Controlled Inputs

In a controlled input, React state drives the value. Every keystroke triggers a state update, and the input displays the state value:

function ContactForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [message, setMessage] = useState('');

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    console.log({ name, email, message });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Name</label>
      <input
        id="name"
        value={name}
        onChange={e => setName(e.target.value)}
      />

      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
      />

      <label htmlFor="message">Message</label>
      <textarea
        id="message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />

      <button type="submit">Send</button>
    </form>
  );
}

Controlled inputs give you full control. You can transform input (uppercase, format phone numbers), validate on every keystroke, and conditionally enable/disable the submit button.

The downside is that every keystroke triggers a re-render of the component. For a small form this is negligible. For a form with 30 fields, it adds up.

Reducing State with a Single Object

Instead of one useState per field, group fields into a single state object:

interface FormData {
  name: string;
  email: string;
  role: string;
  bio: string;
}

function ProfileForm() {
  const [form, setForm] = useState<FormData>({
    name: '',
    email: '',
    role: 'developer',
    bio: '',
  });

  function handleChange(
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
  ) {
    const { name, value } = e.target;
    setForm(prev => ({ ...prev, [name]: value }));
  }

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    console.log(form);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" value={form.name} onChange={handleChange} />

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" value={form.email} onChange={handleChange} />

      <label htmlFor="role">Role</label>
      <select id="role" name="role" value={form.role} onChange={handleChange}>
        <option value="developer">Developer</option>
        <option value="designer">Designer</option>
        <option value="manager">Manager</option>
      </select>

      <label htmlFor="bio">Bio</label>
      <textarea id="bio" name="bio" value={form.bio} onChange={handleChange} />

      <button type="submit">Save</button>
    </form>
  );
}

The name attribute on each input matches the state key, so one handleChange function works for all fields.

Uncontrolled Inputs

Uncontrolled inputs let the DOM manage the value. You read the value when you need it, typically on submit:

function UncontrolledForm() {
  const nameRef = useRef<HTMLInputElement>(null);
  const emailRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const data = {
      name: nameRef.current?.value ?? '',
      email: emailRef.current?.value ?? '',
    };
    console.log(data);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" ref={nameRef} defaultValue="" />

      <label htmlFor="email">Email</label>
      <input id="email" ref={emailRef} type="email" defaultValue="" />

      <button type="submit">Submit</button>
    </form>
  );
}

You can also use FormData to read all values at once:

function FormDataExample() {
  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const data = Object.fromEntries(formData);
    console.log(data);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" />
      <input name="email" type="email" placeholder="Email" />
      <button type="submit">Submit</button>
    </form>
  );
}

Uncontrolled inputs do not trigger re-renders on keystroke. They are simpler and faster for forms where you only need the values on submit.

Building Validation

Add validation to controlled forms by checking values and tracking errors:

interface FormErrors {
  name?: string;
  email?: string;
  password?: string;
}

function SignupForm() {
  const [form, setForm] = useState({ name: '', email: '', password: '' });
  const [errors, setErrors] = useState<FormErrors>({});
  const [touched, setTouched] = useState<Record<string, boolean>>({});

  function validate(values: typeof form): FormErrors {
    const errors: FormErrors = {};
    if (!values.name.trim()) {
      errors.name = 'Name is required';
    }
    if (!values.email.includes('@')) {
      errors.email = 'Enter a valid email address';
    }
    if (values.password.length < 8) {
      errors.password = 'Password must be at least 8 characters';
    }
    return errors;
  }

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const { name, value } = e.target;
    const nextForm = { ...form, [name]: value };
    setForm(nextForm);
    if (touched[name]) {
      setErrors(validate(nextForm));
    }
  }

  function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    setErrors(validate(form));
  }

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const validationErrors = validate(form);
    setErrors(validationErrors);
    setTouched({ name: true, email: true, password: true });

    if (Object.keys(validationErrors).length === 0) {
      console.log('Form submitted:', form);
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          value={form.name}
          onChange={handleChange}
          onBlur={handleBlur}
          aria-invalid={touched.name && !!errors.name}
          aria-describedby={errors.name ? 'name-error' : undefined}
        />
        {touched.name && errors.name && (
          <p id="name-error" role="alert" className="error">
            {errors.name}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          value={form.email}
          onChange={handleChange}
          onBlur={handleBlur}
          aria-invalid={touched.email && !!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {touched.email && errors.email && (
          <p id="email-error" role="alert" className="error">
            {errors.email}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          value={form.password}
          onChange={handleChange}
          onBlur={handleBlur}
          aria-invalid={touched.password && !!errors.password}
          aria-describedby={errors.password ? 'password-error' : undefined}
        />
        {touched.password && errors.password && (
          <p id="password-error" role="alert" className="error">
            {errors.password}
          </p>
        )}
      </div>

      <button type="submit">Sign Up</button>
    </form>
  );
}

This is a lot of code for three fields. This is exactly why form libraries exist.

React Hook Form

React Hook Form uses uncontrolled inputs under the hood for performance, while giving you a controlled-like API:

npm install react-hook-form

The same signup form with React Hook Form:

import { useForm } from 'react-hook-form';

interface SignupData {
  name: string;
  email: string;
  password: string;
}

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupData>();

  async function onSubmit(data: SignupData) {
    await submitToAPI(data);
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          {...register('name', { required: 'Name is required' })}
          aria-invalid={!!errors.name}
        />
        {errors.name && <p role="alert" className="error">{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: 'Enter a valid email address',
            },
          })}
          aria-invalid={!!errors.email}
        />
        {errors.email && <p role="alert" className="error">{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          {...register('password', {
            required: 'Password is required',
            minLength: { value: 8, message: 'Must be at least 8 characters' },
          })}
          aria-invalid={!!errors.password}
        />
        {errors.password && <p role="alert" className="error">{errors.password.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Signing up...' : 'Sign Up'}
      </button>
    </form>
  );
}

That is dramatically less code. React Hook Form handles touched state, validation timing, error tracking, and submission state for you.

Adding Zod Validation

For complex validation, pair React Hook Form with Zod:

npm install zod @hookform/resolvers
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const signupSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100),
  email: z.string().email('Enter a valid email'),
  password: z
    .string()
    .min(8, 'Must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain an uppercase letter')
    .regex(/[0-9]/, 'Must contain a number'),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'],
});

type SignupData = z.infer<typeof signupSchema>;

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupData>({
    resolver: zodResolver(signupSchema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} />
      {errors.name && <p>{errors.name.message}</p>}

      <input type="email" {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register('password')} />
      {errors.password && <p>{errors.password.message}</p>}

      <input type="password" {...register('confirmPassword')} />
      {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}

      <button type="submit">Sign Up</button>
    </form>
  );
}

Zod schemas are reusable. You can use the same schema on the server to validate API requests.

Which Approach to Use

Controlled inputs when you need real-time feedback, input masking, or character-by-character validation. Examples: search-as-you-type, credit card formatting, live character counters.

Uncontrolled inputs for simple forms where you only need values on submit. Examples: search bars, simple contact forms, comment boxes.

React Hook Form for any form with more than a few fields, complex validation, or dynamic fields. It gives you the best of both worlds: performance of uncontrolled inputs with the features of controlled forms.

Wrapping Up

Start simple. For a search bar, a controlled input is fine. For a settings page with many fields, use React Hook Form to avoid the boilerplate of managing errors, touched states, and validation yourself. Add Zod when your validation rules get complex or when you want to share validation between client and server. Whatever approach you choose, always use proper labels, error messages with role="alert", and aria-invalid attributes to keep your forms accessible.