Back to Blog
Frontend16 min readJun 2026

Forms and Validation in React

Forms are where UI bugs go to breed. Learn the controlled-vs-uncontrolled trade-off, why React Hook Form plus a shared zod schema scales, and how to ship accessible, async-validated, multi-step forms with honest submission states.

FormsValidationReact Hook FormZod
SB

Sri Balaji

Founder

On this page

Forms are the most error-prone UI you will build

Who this is for

You can render a React component and wire an `onChange`, but real forms keep biting you: a 30-field page that re-renders on every keystroke, error messages a screen reader never announces, validation that passes on the client and then explodes on the server. This article is for the junior-to-mid React developer who wants a form setup that scales past a login box.

Almost every meaningful interaction in an app is a form: sign-up, checkout, settings, search filters. Forms are also where the most state lives, where accessibility is most often broken, and where the gap between *looks done* and *is done* is widest. The good news: the patterns are well-worn. Get the mental model right once and every form afterward is the same shape.

We will build up from the raw controlled input everyone starts with, move to React Hook Form plus zod for performance and type safety, share one schema between browser and server, then make it accessible, async, multi-step, and honest about its submission state.

A mental model: a good paper form

A form's job is to collect valid data and refuse invalid data, kindly, accessibly, and only once. Everything else is decoration.

Before any code, picture the best paper form you have ever filled in. Each field has a clear label printed next to it, not floating placeholder text that vanishes when you type. When you write something wrong, the correction appears right beside that field, not as a vague banner at the top. And you physically cannot post gibberish: the form is checked before it is accepted. A great React form is exactly this in software.

A printed label next to the boxA <label> tied to the input with htmlFor / id
A correction noted beside the wrong fieldInline error with aria-describedby
The clerk circling the field you missedFocus management: jump to the first error
You cannot post a form full of nonsenseSchema validation before submit
The same rules apply at the post office deskThe same zod schema runs on the server
Every part of a usable form maps to something you already know from paper.

Controlled vs uncontrolled inputs

There are two ways React can know what is in an input. A controlled input stores its value in React state and pushes it back into the DOM every render, React is the single source of truth. An uncontrolled input lets the DOM hold the value and you read it only when you need it, via a ref. The difference looks academic until your form has 40 fields and every keystroke re-renders all of them.

AspectControlledUncontrolled
Source of truthReact stateThe DOM node
Re-renders per keystrokeEvery keystroke re-renders the componentNone, the DOM updates itself
Reading the valueAlways available in stateRead via a ref when needed
Best forSmall forms, live-derived UI, instant formattingLarge forms, performance-sensitive pages
Scales with field countPoorly without memoizationNaturally, work is per-field
Form-library defaultPossible but heavierReact Hook Form embraces this
Controlled vs uncontrolled, the trade-off that decides your form architecture.

The rule of thumb

Reach for controlled inputs when the value drives other UI on every keystroke (a live character counter, a search-as-you-type box). For everything else, and especially anything bigger than a handful of fields, prefer uncontrolled inputs behind a form library. That is the path that scales.

Start with the raw controlled input

Here is the version everyone writes first. It works, and for one or two fields it is perfectly fine. Notice that *you* own every piece of state, every handler, and every validation branch by hand.

ControlledSignup.tsx
tsx
function ControlledSignup() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState<string | null>(null);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!email.includes('@')) {
      setError('Enter a valid email');
      return;
    }
    setError(null);
    // ...submit
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        value={email}                       // React owns the value
        onChange={(e) => setEmail(e.target.value)} // re-render per keystroke
      />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Sign up</button>
    </form>
  );
}

Now multiply that by 30 fields with cross-field rules ('confirm password must match', 'shipping country changes the tax field'). The hand-rolled approach collapses: state spaghetti, a re-render storm, and validation logic scattered across handlers. This is exactly the wall a form library was built to remove.

React Hook Form + zodResolver with typed values

React Hook Form keeps inputs uncontrolled by registering them via refs, so typing in one field does not re-render the others, the form stays fast no matter how many fields it has. We pair it with zod so the *shape and rules* of the data live in one schema, and zodResolver wires that schema into the form. Best of all, z.infer gives us a TypeScript type for free, so the form values are fully typed without us writing an interface.

Signup.tsx
tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// ONE schema = the rules AND the types
export const signupSchema = z.object({
  email: z.string().email('Enter a valid email'),
  password: z.string().min(8, 'At least 8 characters'),
  confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
  message: 'Passwords must match',
  path: ['confirm'], // attach the error to the confirm field
});

// Types derived from the schema, no separate interface
type SignupValues = z.infer<typeof signupSchema>;

export function Signup() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupValues>({
    resolver: zodResolver(signupSchema),
    mode: 'onBlur', // validate when a field loses focus
  });

  const onSubmit = handleSubmit(async (values) => {
    // 'values' is typed as SignupValues, already validated
    await createAccount(values);
  });

  return (
    <form onSubmit={onSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" {...register('email')} />
      {errors.email && <p role="alert">{errors.email.message}</p>}

      <label htmlFor="password">Password</label>
      <input id="password" type="password" {...register('password')} />
      {errors.password && <p role="alert">{errors.password.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create account'}
      </button>
    </form>
  );
}

Compare the two: no useState per field, no manual onChange, validation declared once in the schema, and values arriving in onSubmit already typed and already valid. The noValidate attribute turns off the browser's native popups so *our* accessible messages are the only ones the user sees.

One schema, two runtimes: validate on client AND server

Client validation is a UX nicety. It is not security. Anyone can open dev tools, disable your JavaScript, or POST straight to your endpoint with curl. The server must validate too. The trap most teams fall into is writing the rules twice, once in the React form, once in the API handler, and watching them drift apart. zod fixes this: the schema is plain TypeScript, so you import the *same* object in both places.

One source of truth

Put your schema in a shared module (e.g. `lib/schemas/signup.ts`) and import it into both the form and the route handler. Change a rule once and both sides update. Two copies of validation logic will silently diverge, that is how a 'valid' form ends up rejected by the API.

app/api/signup/route.ts
typescript
import { signupSchema } from '@/lib/schemas/signup'; // the SAME schema

export async function POST(req: Request) {
  const body = await req.json();
  const parsed = signupSchema.safeParse(body); // re-validate on the server

  if (!parsed.success) {
    // Send field-level errors back so the form can show them
    return Response.json(
      { errors: parsed.error.flatten().fieldErrors },
      { status: 422 },
    );
  }

  const { email, password } = parsed.data; // typed + trusted
  await createUser({ email, password });
  return Response.json({ ok: true }, { status: 201 });
}

safeParse never throws, it returns a tagged result you can branch on, and error.flatten().fieldErrors gives you a map of field name to messages that the client can drop straight onto the right inputs. The schema is the contract; both ends honor it. If types feel shaky here, the TypeScript essentials for real apps article covers z.infer and discriminated results in depth.

Accessible error messaging

A red border is invisible to a screen reader and to a colorblind user. Accessible errors are not optional polish, for many users they are the difference between *can* and *cannot* complete the form. Three attributes do most of the work: aria-invalid marks a field as wrong, aria-describedby links the input to its error text so it gets read aloud, and on submit you move focus to the first broken field so keyboard users are not stranded.

AccessibleField.tsx
tsx
const { register, handleSubmit, setFocus, formState: { errors } } =
  useForm<SignupValues>({ resolver: zodResolver(signupSchema) });

// On a failed submit, focus the first field with an error
const onError = (errs: typeof errors) => {
  const first = Object.keys(errs)[0] as keyof SignupValues | undefined;
  if (first) setFocus(first);
};

return (
  <form onSubmit={handleSubmit(onSubmit, onError)} noValidate>
    <label htmlFor="email">Email</label>
    <input
      id="email"
      {...register('email')}
      aria-invalid={errors.email ? 'true' : 'false'}
      aria-describedby={errors.email ? 'email-error' : undefined}
    />
    {errors.email && (
      <p id="email-error" role="alert">
        {errors.email.message}
      </p>
    )}
  </form>
);
  • Every input has a real `<label>` tied with htmlFor/id, placeholders are not labels and disappear on type.
  • `aria-invalid` flips to true only when the field actually has an error, so assistive tech announces the state.
  • `aria-describedby` points at the error element's id so the message is read with the field, not in isolation.
  • `role="alert"` (or an aria-live region) makes new errors announce themselves without the user hunting for them.
  • Focus the first error on failed submit so keyboard and screen-reader users land where the work is.
  • Never rely on color alone, pair the red with an icon and text.

These are table stakes, not gold-plating. For the full picture, landmarks, focus traps, contrast, see web accessibility (a11y).

Async and server-side validation

Some rules cannot be checked offline: 'is this email already taken?' needs a round trip. There are two moments to do this. First, *while typing*, an async, debounced check that gives early feedback. Second, *on submit*, when the server re-validates and may return field errors you must surface. React Hook Form handles both, and you should treat the server's answer as the final word.

async-validation.tsx
tsx
// 1) Async field check while the user types (debounce in real code)
<input
  {...register('email', {
    validate: async (value) => {
      const taken = await isEmailTaken(value);
      return taken ? 'That email is already registered' : true;
    },
  })}
/>;

// 2) Map server-returned errors back onto fields after submit
const { setError } = useForm<SignupValues>(/* ... */);

const onSubmit = handleSubmit(async (values) => {
  const res = await fetch('/api/signup', {
    method: 'POST',
    body: JSON.stringify(values),
  });
  if (res.status === 422) {
    const { errors } = await res.json();
    // errors: { email?: string[]; password?: string[] }
    for (const [field, messages] of Object.entries(errors)) {
      setError(field as keyof SignupValues, {
        message: (messages as string[])[0],
      });
    }
    return;
  }
  // success path
});

Debounce and cancel

Always debounce async field validation (300–500ms) and cancel in-flight requests when the value changes again, or you will fire a request per keystroke and race-condition stale answers onto the screen. The same care you give to [data fetching and server state](/blog/data-fetching-and-server-state) applies here.

Multi-step forms

A long form (account → profile → payment) is friendlier split into steps. The trick is to keep one form instance spanning all steps so the values and validation live in a single place; you just show a slice of the fields at a time and validate that slice before advancing with trigger.

  1. 1

    Define a schema per step

    Either separate zod objects per step, or one big schema you validate field-by-field. Per-step objects keep the rules close to the fields they guard.

  2. 2

    Keep one useForm for the whole flow

    All values live in a single form state. Switching steps just changes which fields are rendered, nothing is lost or remounted.

  3. 3

    Validate the current step before Next

    Call await trigger(['field1','field2']) for this step. Only advance if it returns true, so a user can never carry invalid data forward.

  4. 4

    Submit once at the end

    The final Next is the real submit. Because all values were in one form, handleSubmit gives you the complete, validated object.

MultiStep.tsx
tsx
const [step, setStep] = useState(0);
const { register, handleSubmit, trigger } = useForm<SignupValues>({
  resolver: zodResolver(signupSchema),
});

async function next() {
  // validate only this step's fields before moving on
  const valid = await trigger(step === 0 ? ['email'] : ['password', 'confirm']);
  if (valid) setStep((s) => s + 1);
}

return (
  <form onSubmit={handleSubmit(onSubmit)} noValidate>
    {step === 0 && <input {...register('email')} />}
    {step === 1 && (
      <>
        <input type="password" {...register('password')} />
        <input type="password" {...register('confirm')} />
      </>
    )}
    {step < 1
      ? <button type="button" onClick={next}>Next</button>
      : <button type="submit">Finish</button>}
  </form>
);

Submission states: pending, optimistic, error

A submit button that does nothing visible after a click is a bug, even if the request succeeds, the user will click again, and now you have a duplicate order. Every form has at least three states beyond *idle*: pending while the request is in flight, success (sometimes shown optimistically before the server confirms), and error when it fails. Show all three honestly.

StateWhat the UI must do
PendingDisable the submit button, show a spinner/'Saving...', and block double-submit.
OptimisticShow the expected result immediately; keep a way to roll back if the server rejects it.
SuccessConfirm clearly (toast, redirect, inline check) and reset or move on.
ErrorRe-enable the form, keep the user's input, and surface a specific message, never a silent failure.
The states a submit flow must handle, and what the UI owes the user in each.

Optimistic means reversible

Optimistic updates feel instant, but they are a promise you might have to break. Only go optimistic when you can cleanly roll back on failure and tell the user what happened. For irreversible or money-touching actions, wait for the server and show a plain pending state instead.

Common mistakes that cost hours

  1. Validating only on the client. It is UX, not security. Re-run the same zod schema on the server with safeParse, always.
  2. No real labels. Placeholder-as-label fails screen readers and vanishes on type. Every field gets a <label htmlFor>.
  3. Losing focus on error. After a failed submit, move focus to the first invalid field with setFocus, or keyboard users are stranded.
  4. Blocking paste. Disabling paste on email or password fields (a security myth) breaks password managers and hurts everyone. Never do it.
  5. Re-render storms. Don't control 40 fields by hand; let React Hook Form keep them uncontrolled.
  6. Schema drift. Two copies of the rules, one in the form, one in the API, will diverge. Share one module.

Takeaways

The whole article in seven lines

  • Controlled inputs re-render per keystroke; uncontrolled scale, prefer uncontrolled behind a form library.
  • React Hook Form keeps inputs uncontrolled, so big forms stay fast with almost no boilerplate.
  • Write your rules once in a zod schema; `z.infer` gives you the types for free.
  • Import that SAME schema into the server and `safeParse` every request, client validation is not security.
  • Accessibility is required: real labels, `aria-invalid`, `aria-describedby`, and focus the first error.
  • Handle async checks with debounce, and map server field errors back with `setError`.
  • Show honest submission states, pending, optimistic (only if reversible), success, error, and never let a click look ignored.

Where to go next

Forms sit at the crossroads of types, accessibility, and data fetching, so the natural next steps reinforce each of those foundations.

Build one form this way end to end, uncontrolled fields, a shared zod schema, accessible errors, real submission states, and you will reuse the exact same skeleton for every form you write afterward.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.