iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Controlled vs Uncontrolled

A controlled input has its value driven by React state — every keystroke triggers onChange, which updates state, which re-renders the input. The opposite is uncontrolled (the DOM owns the value, you reach in via a ref). Controlled is the default for forms with validation, normalisation, or cross-field rules; uncontrolled wins for file inputs and simple one-off submissions.

Controlled form with validation + a Zod schema

EXAMPLE
import { useState, useRef } from 'react';
import { z } from 'zod';

// 1) Controlled inputs — state is the source of truth
const Schema = z.object({
  email: z.string().email('Invalid email'),
  name:  z.string().min(2, 'Name is too short'),
  age:   z.coerce.number().int().min(18, 'Must be 18+'),
  agree: z.literal(true, { errorMap: () => ({ message: 'Required' }) }),
});

type FormValues = z.input<typeof Schema>;

const initial: FormValues = { email: '', name: '', age: 0 as any, agree: false };

export default function Signup() {
  const [values, setValues] = useState<FormValues>(initial);
  const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({});
  const [submitting, setSubmitting] = useState(false);

  function set<K extends keyof FormValues>(k: K, v: FormValues[K]) {
    setValues((prev) => ({ ...prev, [k]: v }));
  }

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    const parsed = Schema.safeParse(values);
    if (!parsed.success) {
      const next: typeof errors = {};
      for (const issue of parsed.error.issues) next[issue.path[0] as keyof FormValues] = issue.message;
      setErrors(next);
      return;
    }
    setErrors({});
    setSubmitting(true);
    try {
      await fetch('/api/signup', { method: 'POST', body: JSON.stringify(parsed.data) });
      setValues(initial);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={onSubmit} noValidate className='space-y-2'>
      <Field label='Email' error={errors.email}>
        <input value={values.email}
               onChange={(e) => set('email', e.target.value)}
               type='email' autoComplete='email' className='input' />
      </Field>
      <Field label='Name' error={errors.name}>
        <input value={values.name}
               onChange={(e) => set('name', e.target.value)}
               className='input' />
      </Field>
      <Field label='Age' error={errors.age}>
        <input value={values.age as any}
               onChange={(e) => set('age', e.target.value as any)}
               inputMode='numeric' className='input' />
      </Field>
      <Field error={errors.agree}>
        <label className='inline-flex items-center gap-2'>
          <input type='checkbox' checked={values.agree}
                 onChange={(e) => set('agree', e.target.checked)} />
          I agree to the terms
        </label>
      </Field>
      <button disabled={submitting} className='btn'>{submitting ? 'Saving...' : 'Create account'}</button>
    </form>
  );
}

function Field({ label, error, children }: { label?: string; error?: string; children: React.ReactNode }) {
  return (
    <div>
      {label && <label className='block text-sm'>{label}</label>}
      {children}
      {error && <p className='text-red-600 text-sm'>{error}</p>}
    </div>
  );
}

// 2) Uncontrolled — file inputs and very simple forms
function UploadAvatar() {
  const ref = useRef<HTMLInputElement>(null);
  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    const file = ref.current?.files?.[0];
    if (!file) return;
    const body = new FormData();
    body.append('file', file);
    await fetch('/api/avatar', { method: 'POST', body });
  }
  return (
    <form onSubmit={onSubmit}>
      <input ref={ref} type='file' accept='image/*' />
      <button>Upload</button>
    </form>
  );
}

// 3) Performance — debounce expensive validation
//    A controlled input with synchronous schema parsing on every keystroke
//    is fine; debounce only when validation crosses the network.

// 4) Forms libraries — React Hook Form / Formik
//    Once you have 5+ fields, reach for react-hook-form: it tracks dirty / touched,
//    uses a single ref-based subscription per field, and integrates with Zod via
//    @hookform/resolvers/zod.

Why it matters

Controlled inputs make validation and cross-field rules trivial; the cost is a re-render per keystroke. For large forms, prefer react-hook-form — it keeps the controlled mental model but subscribes per field, so typing in one input does not re-render the others, which keeps frame budgets healthy on long forms.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// Controlled — React owns the value.
<input value={text} onChange={e => setText(e.target.value)} />
// Uncontrolled — DOM owns it; read via ref.
Try it Yourself »

Discussion

Loading…