launch-quickly
Patterns

Forms

How to build a form in this project — useActionForm, the Field primitive, where the schema comes from, and how server-side field errors get back to the right input. Use when adding or changing any form, or when RHF's error types will not line up.

Generated from .claude/skills/forms/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.

Application forms use useActionForm, which binds react-hook-form's resolver to the action's own schema and maps server field errors back onto the fields that caused them.

The shape

'use client';

export function ProjectForm({ project }: { project?: Project }) {
  const router = useRouter();

  const { form, submit, pending, error } = useActionForm(
    async (values) =>
      project
        ? editProject({ ...values, id: project.id })
        : createProject(values),
    createProjectSchema, // the SAME object the action validates with
    {
      defaultValues: {
        name: project?.name ?? '',
        status: project?.status ?? 'draft',
      },
      onSuccess: (saved) => {
        toast.success('Project created.');
        router.push(`/projects/${saved.id}`);
        router.refresh();
      },
    },
  );

  const { register, formState } = form;

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        void submit(event);
      }}
      noValidate
    >
      <Field error={formState.errors.name}>
        <FieldLabel>Name</FieldLabel>
        <NameInput {...register('name')} />
      </Field>

      {error ? <p role="alert">{error}</p> : null}

      <Button type="submit" disabled={pending}>
        {pending ? 'Saving…' : 'Save'}
      </Button>
    </form>
  );
}

// One small wrapper per control, below the component.
function NameInput(props: React.ComponentProps<'input'>) {
  return <Input placeholder="Migration plan" {...useFieldProps()} {...props} />;
}

Returns: form (the RHF instance), submit, pending, error (the non-field message), result.

Always wrap a control in <Field>

<Field> wires the label, aria-invalid and aria-describedby once instead of at every call site. Accessible error association is easy to get subtly wrong in twenty places; do it in one.

FieldLabel takes no htmlFor and useFieldProps() takes no arguments. Both read the generated id from FieldContext, so you never write an id yourself.

useFieldProps() must be called inside the <Field> provider. A hook cannot run in a spread at the call site, so each control gets a tiny wrapper component — that is what NameInput above is for, and why the shipped form has one per field. It looks like ceremony until you try it the other way and the aria attributes silently never arrive.

Why not bare useActionState

Authenticated forms need per-field errors, dirty tracking and field arrays, which useActionState alone gives you only via hand-rolled boilerplate. Progressive enhancement without JavaScript is not a real requirement behind a login wall.

Marketing forms are the documented exception and use plain useActionState + FormData — see src/features/waitlist.

The schema is imported, never redeclared

From the feature's validation.ts. One object serves the action's validation and the form's resolver, so client and server cannot disagree. Need a variant? Derive it with .extend() / .pick() / .omit().

Server field errors

When an action throws validation(message, { name: 'Already taken.' }), those fieldErrors come back through ActionResult and useActionForm sets them on the matching RHF fields. So a uniqueness violation that only the database can detect still lands under the right input rather than as a banner.

The error-type trap

react-hook-form widens formState.errors.x to Merge<FieldError, FieldErrorsImpl<…>> for nullable or transformed fields. A hand-written form whose schema has no transforms never hits it; a generated one does, which is how this surfaced.

<Field> therefore types its error prop structurally as { message?: React.ReactNode } rather than as RHF's FieldError. If you write a component that accepts an error, do the same — do not import FieldError and do not cast.

Zod input vs output

useActionForm tracks both sides of the split:

useForm<z.input<TSchema>, unknown, z.output<TSchema>>;

This is why a schema with .transform() or a coerced number works: the form holds the input type while the action receives the output type. If you fight the types here, check whether you meant z.input or z.output before adding a cast.

Don't reach for a state library

  • server state → RSC + server actions
  • URL state (filters, pagination, tabs, search) → nuqs
  • ephemeral local UI → useState

Redux, MobX, Jotai, Recoil, SWR and Context holding server data are banned by lint. Zustand is allowed only in src/features/<name>/store.ts, for genuinely client-global non-server state — a command palette, a canvas, a multi-step wizard.

Anything bookmarkable belongs in the URL. If a user can reload the page and lose their filters, that is a bug.

On this page