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.
Authorization
Who may do what — role permissions with can(), and resource-level rules (owner, assignee) in a slice's policy.ts. Use when a rule depends on the ROW and not just the role, or when tempted to compare ctx.userId in a component.
UI and navigation
How the signed-in shell works — nav.ts as the single navigation declaration, permission-gated sidebar, breadcrumbs, theme presets, and how a new page becomes reachable. Use when adding a route, changing navigation, or theming.