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.
registry/patterns/validate-on-blur-then-change/validate-on-blur-then-change.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>
)
}