Skip to content

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.

TSX · app/agents/new/actions.ts
"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.

TSX · app/agents/new/form.tsx
"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.

TSX · new-agent-form.tsx
"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

With the candor-ui CLI

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 native required, so the browser's own bubbles don't speak over your messages. Add noValidate to 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 attempt in 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.