Validation Strategies

Three layers earn their place. The browser's own constraint validation — required, type="email", min, maxLength, pattern — costs nothing, is accessible by default, and blocks the submit before React 7,897 sees it. Client-side schema validation gives precise, per-field messages. Server-side validation is the only layer that is a guarantee, because a client can be scripted.

Timing matters as much as the rules. Validating on every keystroke tells users they are wrong while they are still typing; validating on blur, then re-validating on change only for fields already marked invalid, is what React Hook Form 351,018 does by default. One schema keeps the layers honest by being shared: Zod 4.6.5 44,027 (github.com/colinhacks/zod (https://github.com/colinhacks/zod 44,027 ), MIT, npm 2,036 install zod) is the common choice, with Yup 1.7.1 23,661 and Valibot 1.5.0 9,023 in the same slot.

Validating a form action against a Zod schemaJavaScript
import { z } from 'zod';
const Signup = z.object({
  email: z.email('Enter a valid email address.'),
  age: z.coerce.number().int().min(18, 'You must be 18 or older.')
});
export async function signup(previous, formData) {
  const parsed = Signup.safeParse(Object.fromEntries(formData));
  if (!parsed.success)                          // render these next to the fields
    return { errors: z.flattenError(parsed.error).fieldErrors };
  await fetch('https://api.example.com/signup', { method: 'POST', body: formData });
  return { errors: {}, ok: true };
}

Object.fromEntries(formData) turns the form into a plain object, and safeParse returns a result instead of throwing — exactly the shape useActionState wants. Import the same module on the server and you have one definition of "valid" for both sides. Announce errors with aria-invalid="true" plus an aria-describedby pointing at the message element, or a screen-reader user will never hear them.