# Validate on Blur, then on Change

> The "reward early, punish late" strategy: a field stays quiet while typing, validates when you leave it, and once it has shown an error it re-validates on every keystroke so the message clears the moment it is fixed. Use this when live validation feels nagging but a stale error feels broken.

A shadcn/ui block from the [@tealmac](https://ui.tealmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/tealmac/validate-on-blur-then-change).

- Page, with a live preview: https://shadcn.exchange/r/tealmac/validate-on-blur-then-change
- Screenshot: https://shadcn.exchange/shot/tealmac/validate-on-blur-then-change/light.png
- Registry item: https://ui.tealmac.com/r/validate-on-blur-then-change.json
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/tealmac/validate-on-blur-then-change.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `field`, `input`, `@tealmac/form-store`

## Files

### `registry/patterns/validate-on-blur-then-change/validate-on-blur-then-change.tsx`

```tsx
"use client"

import * as React from "react"

import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field"
import { useFormStore } from "@/components/ui/form-store"
import { Input } from "@/components/ui/input"

const rules = {
  email: (v: string) =>
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
      ? []
      : ["Enter an email like name@example.com."],
  code: (v: string) => (/^\d{6}$/.test(v) ? [] : ["The code has 6 digits."]),
}

export function ValidateOnBlurThenChange() {
  // The form store validates on blur and on change; a field shows messages only after it has been
  // blurred once (touched), and from then on every change updates them. That is the whole strategy.
  const form = useFormStore({
    defaultValues: { email: "", code: "" },
    validators: {
      change: (v) => ({
        email: rules.email(v.email),
        code: rules.code(v.code),
      }),
    },
  })

  return (
    <form
      noValidate
      className="flex w-full max-w-sm flex-col gap-4"
      onSubmit={form.handleSubmit}
    >
      {(["email", "code"] as const).map((name) => (
        <form.Field<string> key={name} name={name}>
          {(field) => (
            <Field data-invalid={field.invalid || undefined}>
              <FieldLabel htmlFor={`vbc-${name}`}>
                {name === "email" ? "Email" : "Verification code"}
              </FieldLabel>
              <Input
                id={`vbc-${name}`}
                inputMode={name === "code" ? "numeric" : undefined}
                {...field.inputProps}
              />
              {field.invalid ? null : (
                <FieldDescription>
                  {field.touched && field.allErrors.length === 0
                    ? "Looks good."
                    : "Checked when you leave the field."}
                </FieldDescription>
              )}
              <FieldError errors={field.errors} />
            </Field>
          )}
        </form.Field>
      ))}
    </form>
  )
}
```
