Form
A form primitive built on react-hook-form + zod. Provides Form/FormField/FormItem/FormLabel/FormControl/FormDescription/FormMessage with automatic aria wiring and error display.
Installation
pnpm dlx @hex-core/cli add formSign-up form with multi-field zod resolver
Multi-field schema with cross-field refinement (password confirmation). Each FormField wires its render-prop field to FormControl so aria-invalid + aria-describedby are managed automatically.
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
const signUpSchema = z
.object({
email: z.string().email("Enter a valid email address"),
password: z.string().min(8, "At least 8 characters"),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "Passwords do not match",
path: ["confirm"],
});
type SignUpValues = z.infer<typeof signUpSchema>;
export function SignUpForm() {
const form = useForm<SignUpValues>({
resolver: zodResolver(signUpSchema),
defaultValues: { email: "", password: "", confirm: "" },
});
const onSubmit = form.handleSubmit(async (values) => {
await fetch("/api/sign-up", { method: "POST", body: JSON.stringify(values) });
});
return (
<Form {...form}>
<form onSubmit={onSubmit} className="grid gap-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Work email</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" placeholder="you@company.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" autoComplete="new-password" {...field} />
</FormControl>
<FormDescription>At least 8 characters.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormControl>
<Input type="password" autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Creating account…" : "Create account"}
</Button>
</form>
</Form>
);
}Field array (invitee list)
useFieldArray manages a dynamic list of rows — each invitee gets its own FormField with append() / remove() controls. Common pattern for invite flows, dynamic env-var editors, and contact lists.
import { zodResolver } from "@hookform/resolvers/zod";
import { useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
const inviteSchema = z.object({
invitees: z
.array(z.object({ email: z.string().email("Enter a valid email") }))
.min(1, "Add at least one invitee"),
});
type InviteValues = z.infer<typeof inviteSchema>;
export function InviteTeammatesForm() {
const form = useForm<InviteValues>({
resolver: zodResolver(inviteSchema),
defaultValues: { invitees: [{ email: "" }] },
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "invitees",
});
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => console.log(values))}
className="grid gap-4"
>
<div className="grid gap-3">
{fields.map((row, index) => (
<FormField
key={row.id}
control={form.control}
name={`invitees.${index}.email` as const}
render={({ field }) => (
<FormItem>
<FormLabel className={index === 0 ? undefined : "sr-only"}>
Invitee email
</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input type="email" placeholder="teammate@company.com" {...field} />
</FormControl>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(index)}
disabled={fields.length === 1}
aria-label={`Remove invitee ${index + 1}`}
>
Remove
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ email: "" })}
>
Add another
</Button>
<Button type="submit">Send invites</Button>
</div>
</form>
</Form>
);
}API Reference
No props.
AI Guidance
When to use
Use for any form that needs validation, per-field errors, and accessible aria-describedby/aria-invalid wiring. Pair with zod schemas via @hookform/resolvers/zod.
When not to use
Don't use for trivial single-input forms that don't need validation (render a plain Input + Button). Don't use for server actions forms in Next.js 16 (consider useActionState + useFormStatus instead).
Common mistakes
- Forgetting to spread {...field} into the form control (connects value/onChange)
- Putting FormLabel outside FormItem (loses htmlFor wiring)
- Using useForm without a resolver when zod validation is desired
- Calling form.handleSubmit without a callback
Accessibility
FormControl automatically wires id, aria-describedby, and aria-invalid. FormLabel uses htmlFor matching the control id. Errors are announced via FormMessage with matching aria-describedby.
Token budget: 900
Verified against @hex-core/components@1.12.0