Client-Side Validation Example

v4

A signup form with onChange client-side Zod validation. The same schema used on the server validates fields instantly in the browser — no duplicate validation logic needed.

3–20 characters, letters, numbers, underscores

8+ characters, one uppercase, one number

Try admin as username or taken@example.com as email to see server-side errors (after client validation passes).

How It Works

1.

withZod(schema, action) wraps the server action and attaches the Zod schema via __schema.

2.

useActionForm auto-detects the attached schema and subscribes to field changes with form.watch().

3.

In onChange mode, every keystroke runs a partial safeParse and sets/clears field errors instantly.

4.

On submit, client validation runs first. Only if all fields pass does the server action execute, saving a round-trip for obvious errors.

5.

Server-side errors (like "username taken") still come through and are merged into RHF's error state seamlessly.

Source Code

'use client'
import { useActionForm } from 'hookform-action'
import { signupAction } from './actions'
import { signupSchema } from './schema'

export function SignupForm() {
  // Same Zod schema used on both client and server
  const { register, handleSubmit, formState: { errors, isPending } } =
    useActionForm(signupAction, {
      defaultValues: { username: '', email: '', password: '' },
      schema: signupSchema,        // enables client-side validation
      validationMode: 'onChange',   // 'onBlur' | 'onSubmit' also available
    })

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

      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

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

      <button disabled={isPending}>Create Account</button>
    </form>
  )
}