Patterns / Forms and validation
Forms and validation with shadcn/ui and zod
Every app around an agent has forms: name an agent, set where it escalates, correct a value it extracted. Field ties a label, a hint, an error and a required mark to the control it wraps: the label points at the control, the control is described by the hint and the error, and it is aria-invalid while there is one.
Validation stays yours. Check the values with zod, in a server action or with react-hook-form, and pass each field its message. Field shows what you pass and never makes up an error. Give it the form's submit count as attempt, and after a failed submit focus moves to the first field with a problem, which rings once.
On a long form, FormErrorSummary lists the problems at the top, each a link that moves focus to its field. NumberInput, TimeInput, PasswordInput, OneTimeCode and Combobox (one option, or several with multiple) all take what Field passes in.
The components
- FieldProA control with its label, hint, error and required mark, wired together; focus and one ring on the first problem after a submit.
- Form error summaryProThe form's problems at its top, each a link to its field.
- InputProA text field that rings once when Field points at it.
- TextareaProThe same for several lines.
- ComboboxProChoose one, or several with `multiple`, from a list you can search.
- Number inputProA number with step buttons and a unit; empty is null, never 0.
- Time inputProA time of day as "HH:MM", with the zone it is in.
- Password inputProA password with a Show or Hide button that says what it does.
- One time codeProA code in a row of boxes, one field underneath for autofill and paste.
- ButtonFreeSubmits the form; it stays usable while there are errors, so the summary can say what to fix.
See it working
The code
A server action that checks the form with zod and returns one message per field.
"use server"
import { z } from "zod"
const NewAgent = z.object({
name: z.string().trim().min(1, "Enter a name for the agent"),
email: z.email("Enter an email address, like name@company.com"),
})
export type Fields = "name" | "email"
export type NewAgentState = {
/** Goes up on every submit, so the form can point at the first problem. */
attempt: number
errors: Partial<Record<Fields, string>>
/** What was sent, so the fields keep it after React resets the form. */
values: Partial<Record<Fields, string>>
}
export async function createAgent(previous: NewAgentState, data: FormData): Promise<NewAgentState> {
const values = { name: String(data.get("name") ?? ""), email: String(data.get("email") ?? "") }
const parsed = NewAgent.safeParse(values)
if (!parsed.success) {
const errors: NewAgentState["errors"] = {}
for (const issue of parsed.error.issues) {
const field = issue.path[0] as Fields
errors[field] ??= issue.message
}
return { attempt: previous.attempt + 1, errors, values }
}
await saveAgent(parsed.data) // your own data layer
return { attempt: previous.attempt + 1, errors: {}, values: {} }
}The form: each Field gets its message and the submit count.
"use client"
import { useActionState } from "react"
import { Button } from "@/components/candor/ui/button"
import { Field } from "@/components/candor/ui/field"
import { FormErrorSummary } from "@/components/candor/ui/form-error-summary"
import { Input } from "@/components/candor/ui/input"
import { createAgent, type Fields } from "./actions"
const order: Fields[] = ["name", "email"]
export function NewAgentForm() {
const [state, action] = useActionState(createAgent, { attempt: 0, errors: {}, values: {} })
const summary = order.flatMap((id) => (state.errors[id] ? [{ id, message: state.errors[id] }] : []))
return (
<form action={action} noValidate className="flex max-w-sm flex-col gap-4">
<FormErrorSummary errors={summary} />
<Field id="name" label="Agent name" required error={state.errors.name} attempt={state.attempt}>
<Input name="name" defaultValue={state.values.name} />
</Field>
<Field id="email" label="Escalation email" hint="Where the agent sends what it can't decide." required error={state.errors.email} attempt={state.attempt}>
<Input name="email" type="email" autoComplete="email" defaultValue={state.values.email} />
</Field>
<Button type="submit" className="self-start">Create agent</Button>
</form>
)
}The same form with react-hook-form, whose submitCount is the attempt.
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/candor/ui/button"
import { Field } from "@/components/candor/ui/field"
import { Input } from "@/components/candor/ui/input"
const NewAgent = z.object({
name: z.string().trim().min(1, "Enter a name for the agent"),
email: z.email("Enter an email address, like name@company.com"),
})
export function NewAgentForm({ onCreate }: { onCreate: (agent: z.infer<typeof NewAgent>) => Promise<void> }) {
const { register, handleSubmit, formState } = useForm({ resolver: zodResolver(NewAgent), defaultValues: { name: "", email: "" } })
const { errors, submitCount } = formState
return (
<form onSubmit={handleSubmit(onCreate)} noValidate className="flex max-w-sm flex-col gap-4">
<Field label="Agent name" required error={errors.name?.message} attempt={submitCount}>
<Input {...register("name")} />
</Field>
<Field label="Escalation email" required error={errors.email?.message} attempt={submitCount}>
<Input type="email" autoComplete="email" {...register("email")} />
</Field>
<Button type="submit" className="self-start">Create agent</Button>
</form>
)
}Install
Each component brings in what it depends on. The free ones install as they are; Pro and Premium ones need a licence key.
Good to know
- Write errors that say how to fix the value ("Enter an email address, like name@company.com"), not only that it is wrong. The same words go in the field and in the summary.
- Field sets
aria-required, not the nativerequired, so the browser's own bubbles don't speak over your messages. AddnoValidateto the form for the same reason. - React resets a form after its action runs. Return what was sent with the errors and pass it back as
defaultValue, or the person retypes everything. - Change
attemptin the same render as the errors it found. A field that only gains an error while the person types is left alone: no focus move, no ring.