# 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.

Web page: https://candor.themethodic.com/patterns/forms-and-validation

## Components

- **Field** (`field`, Pro; https://candor.themethodic.com/docs/field.md): A control with its label, hint, error and required mark, wired together; focus and one ring on the first problem after a submit.
- **Form error summary** (`form-error-summary`, Pro; https://candor.themethodic.com/docs/form-error-summary.md): The form's problems at its top, each a link to its field.
- **Input** (`input`, Pro; https://candor.themethodic.com/docs/input.md): A text field that rings once when Field points at it.
- **Textarea** (`textarea`, Pro; https://candor.themethodic.com/docs/textarea.md): The same for several lines.
- **Combobox** (`combobox`, Pro; https://candor.themethodic.com/docs/combobox.md): Choose one, or several with `multiple`, from a list you can search.
- **Number input** (`number-input`, Pro; https://candor.themethodic.com/docs/number-input.md): A number with step buttons and a unit; empty is null, never 0.
- **Time input** (`time-input`, Pro; https://candor.themethodic.com/docs/time-input.md): A time of day as "HH:MM", with the zone it is in.
- **Password input** (`password-input`, Pro; https://candor.themethodic.com/docs/password-input.md): A password with a Show or Hide button that says what it does.
- **One time code** (`one-time-code`, Pro; https://candor.themethodic.com/docs/one-time-code.md): A code in a row of boxes, one field underneath for autofill and paste.
- **Button** (`button`, free; https://candor.themethodic.com/docs/button.md): Submits the form; it stays usable while there are errors, so the summary can say what to fix.

## Install

```bash
npx candor-ui add field form-error-summary input button
```

## Code

`app/agents/new/actions.ts`: A server action that checks the form with zod and returns one message per field.

```tsx
"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: {} }
}
```

`app/agents/new/form.tsx`: The form: each Field gets its message and the submit count.

```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>
  )
}
```

`new-agent-form.tsx`: The same form with react-hook-form, whose `submitCount` is the attempt.

```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>
  )
}
```

## Notes

- 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.
