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.

Your public display name.

We'll never share your email.

Installation

pnpm
pnpm dlx @hex-core/cli add form

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

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

tsx
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