Form toolkit
StableuseForm for values, errors and validation timing, plus the checks and GOV.UK-style messages every form in the kit shares.
useForm holds a form’s values and errors and decides when to check: everything on submit; a field with an error again on every change, so the error clears the moment it is fixed; format checks on blur, but only after a change. The checks return the message to show, or undefined, and the messages read the same inline and in a summary. The sign-in flow, account settings and the contact form all use them.
Validate on submit
import { useRef, useState, type FormEvent } from 'react'import { flushSync } from 'react-dom'import { checkEmail, checkName, useForm } from '@rchursin/product/forms'import { Button, Field, Select, TextInput } from '@rchursin/product/ui'
const empty = { name: '', email: '', role: 'Member' }const roles = ['Member', 'Admin', 'Viewer']
/** * useForm with the shared checks. Everything is checked on submit and focus goes to the first * field to fix; a field with an error re-checks as you type; the email's format is also checked * when you leave it, but only after a change. */export default function FormToolkitUseForm() { const form = useForm(empty, { name: (v) => checkName(v.name), email: (v) => checkEmail(v.email) }, { onBlur: ['email'] }) const refs = { name: useRef<HTMLInputElement>(null), email: useRef<HTMLInputElement>(null) } const [sent, setSent] = useState('')
function submit(e: FormEvent) { e.preventDefault()Usage
import { useForm, checkEmail, checkName, messages } from '@rchursin/product/forms'const form = useForm({ email: '' }, { email: (v) => checkEmail(v.email) }, { onBlur: ['email'] })
<Field label="Email" error={form.errors.email}> {(p) => <TextInput {...p} type="email" {...form.bind('email')} />}</Field>Examples
What the checks return
A message to show, or undefined. Every result here is computed live from the real functions.
import { charLength, checkCode, checkEmail, checkName, checkNewPassword, isEmail } from '@rchursin/product/forms'
/** Each check returns the message to show, or undefined when the value is fine. Results below are live. */const calls: [string, unknown][] = [ ["checkEmail('')", checkEmail('')], ["checkEmail('alex@example')", checkEmail('alex@example')], ["checkEmail(' alex@example.com ')", checkEmail(' alex@example.com ')], ["checkName(' ')", checkName(' ')], ["checkNewPassword('hunter2')", checkNewPassword('hunter2')], ["checkNewPassword('correct horse battery staple')", checkNewPassword('correct horse battery staple')], ["checkNewPassword('lantern orbit velvet')", checkNewPassword('lantern orbit velvet')], ["checkCode('4242')", checkCode('4242')], ["isEmail('alex@example.co')", isEmail('alex@example.co')], // “Zoë” typed with a combining mark, then 𠮷田 (Yoshida), whose first character takes two UTF-16 units ["charLength('Zoe\\u0308 𠮷田')", charLength('Zoe\u0308 𠮷田')], ["'Zoe\\u0308 𠮷田'.length", 'Zoe\u0308 𠮷田'.length],]
const show = (v: unknown) => (typeof v === 'string' ? `“${v}”` : String(v))
export default function FormToolkitChecks() { return ( <dl className="flex w-full max-w-xl flex-col divide-y divide-line text-xs"> {calls.map(([call, result]) => ( <div key={call} className="flex flex-col gap-1 py-2.5 first:pt-0 last:pb-0"> <dt className="font-mono text-[11px] break-words text-fg-2">{call}</dt> <dd className={result === undefined ? 'text-fg-3' : 'text-fg'}> <span aria-hidden="true" className="mr-1.5 font-mono text-fg-3"> → </span> {show(result)} </dd> </div> ))} </dl> )}The messages
Every sentence in messages, by key. Use them in your own checks so a problem reads the same everywhere.
import { messages } from '@rchursin/product/forms'
/** * Every message the kit can show, in GOV.UK’s style: an instruction when a field is empty, the * problem when a value is wrong. Never “invalid”, “please” or “This field is required”. */export default function FormToolkitMessages() { return ( <dl className="grid w-full max-w-2xl grid-cols-1 gap-x-6 gap-y-3 text-[13px] sm:grid-cols-[auto_minmax(0,1fr)] sm:gap-y-2.5"> {Object.entries(messages).map(([key, text]) => ( <div key={key} className="flex flex-col gap-0.5 sm:contents"> <dt className="font-mono text-[11px] text-fg-3 sm:pt-0.5">{key}</dt> <dd className="text-pretty text-fg">{text}</dd> </div> ))} </dl> )}API
useForm(initial, validators, options)
| Prop | Type | Default | Description |
|---|---|---|---|
initialrequired | V extends Record<string, string | boolean> | – | Starting values. Text fields are strings, checkboxes booleans. |
validatorsrequired | Validators<V> | – | { [field]: (values) => string | undefined }. Each gets every value, so a rule can compare fields (new vs current password). |
options.onBlur | (keyof V)[] | – | Fields to check on blur: only after a change, never when empty, after 200ms, and not when focus goes to a submit button (the press is about to check everything, and an error appearing would push the button away). |
What useForm returns
| Prop | Type | Default | Description |
|---|---|---|---|
values | V | – | Current values. |
errors | Errors<V> | – | { [field]?: string }: the message to show under each field. |
bind | (key) => { value, onChange, onBlur } | – | Spread onto a text input or textarea. |
set | (key, value) => void | – | Set one value (checkboxes, comboboxes). Re-checks the field if it shows an error. |
validate | () => (keyof V)[] | – | Runs every rule and returns the fields that failed, in field order. Wrap it in flushSync before moving focus. |
setErrors | Dispatch<SetStateAction<Errors<V>>> | – | Put your server’s answers under their fields. |
reset | (to: V) => void | – | New values, no errors, nothing marked as changed. |
blur | (key, next?: EventTarget | null) => void | – | What bind’s onBlur calls, for controls you wire yourself. |
Checks
| Prop | Type | Default | Description |
|---|---|---|---|
checkEmail | (value: string) => string | undefined | – | “Enter your email address” when empty; the format message when it is not like name@example.com. |
checkName | (value: string) => string | undefined | – | Empty, or longer than NAME_MAX (70) characters. |
checkNewPassword | (value: string) => string | undefined | – | NIST SP 800-63B-4: shorter than PASSWORD_MIN (15), longer than PASSWORD_MAX (128), or on the blocklist. Nothing else. |
checkCode | (value: string, length?: number) => string | undefined | – | Empty, or not exactly length characters long (default 6). It counts, it doesn’t look: clean the value with digitsOnly first, as OtpInput does. The messages say “6-digit”. |
isEmail | (value: string) => boolean | – | Deliberately loose: something@something.something. The only real test is sending a message. |
charLength | (value: string) => number | – | Length in code points after NFC, not UTF-16 units: “é” typed with a combining accent counts once. |
messages | { readonly emailEmpty: string; … } (as const) | – | Every error sentence, by key: emailEmpty, wrongCredentials, passwordShort… |
Accessibility
- The messages follow GOV.UK: an instruction when a field is empty, the problem when a value is wrong, and never “invalid”, “please” or “This field is required”. The same words appear inline and in an error summary.
- An error never appears for the first time while someone is typing. After it shows, it is re-checked on each change, so it clears as soon as the value is fixed.
- useForm doesn’t move focus. After validate(), focus the first failed field (render the errors first with flushSync, so the field is read with its message), or show an ErrorSummary on long forms.
Guidelines
Do
- Validate on submit, then focus the first field in the list validate() returns.
- Run the same checks on your server and put its answers into setErrors.
- Use charLength for any limit people can see.
Don’t
- Don’t check on every keystroke before the first submit.
- Don’t write your own wording for a problem the messages already cover.
- Don’t make isEmail stricter; send a code instead.
In use
Related
- FieldLabel above, hint under it, error in words between the hint and the control, with the ids and aria wiring done for you.
- Error summaryA “There is a problem” box at the top of a form that takes focus after a failed submit and links each message to its field.
- Contact formChecks like GOV.UK, keeps every word through a failed send, and sends through your function, Formspree, Web3Forms or no backend at all.
- Sign-in flowThe whole front door in one component: email first, password or passkey, GitHub or Google, sign-up with a code, and recovery.
- Password strengthAn advisory meter that guesses like an attacker, plus the blocklist check. Four grey segments and a word; it never blocks.
- Character countA textarea that counts down to its limit, GOV.UK style: it never cuts text off, and speaks the count when typing pauses.
Source
The component’s own files, exactly as they run on this site.
/** * Field values, errors and validation timing for one form. * * Timing (from the research: GOV.UK, Wroblewski's inline-validation study, Baymard, Primer): * 1. Validate everything on submit. * 2. Once a field shows an error, re-check it as the person types, so the error clears the * moment the value is fixed (and never appears for the first time mid-word). * 3. Format checks (email shape, password length) also run on blur — but only after the * person changed the field, and never for an empty one. The check waits a moment, and is * skipped when focus goes to a submit button: an error appearing above the field would push * the button down mid-click, and the submit is about to check everything anyway. * * const form = useForm({ email: '' }, { email: (v) => checkEmail(v.email) }, { onBlur: ['email'] }) * <Field error={form.errors.email}>{(p) => <TextInput {...p} {...form.bind('email')} />}</Field> */import { useCallback, useEffect, useRef, useState, type ChangeEvent, type FocusEvent } from "react"
const BLUR_DELAY = 200
export type Validators<V> = { [K in keyof V]?: (values: V) => string | undefined }