Forms
The react-hook-form + Zod + shadcn Form pattern — the glue, a real recipe, binding non-native inputs, and submit handling.
Every form on the school website is built the same way, on purpose. There is one pattern — react-hook-form drives the state, Zod validates it, and the shadcn Form wrapper glues the two to your inputs and wires up the accessibility. Once you've seen it once, every admission form, contact form, and dialog form on the site reads the same, and adding a new one is mostly mechanical.
The mental model is a three-way split. react-hook-form owns the values (what's typed, what's touched, whether it's valid). Zod owns the rules (this field is required, that one must be an email). The shadcn Form components own the wiring — they connect a value to its input, hang the error message underneath, and set the aria-* attributes so screen readers know what's invalid. You write the schema and the markup; the glue does the boring, error-prone middle.
The stack, in one line
react-hook-form 7 + Zod v4 + @hookform/resolvers/zod, surfaced through the shadcn Form components in app/components/ui/form.tsx. That's the whole toolkit — there is no second forms library to learn.
How the Form glue works
ui/form.tsx is small, and it pays to understand it because everything else leans on it. It's five cooperating pieces:
Formis literally react-hook-form'sFormProvider— it just publishes yourformobject to everything inside it.FormFieldwraps RHF'sControllerand broadcasts the field'snamethrough a context, so the parts below it know which field they belong to.useFormField()reads that context and derives the inputid, the description/message ids, and whether the field is in error.FormControlis a RadixSlotthat injects those ids andaria-invalidonto whatever input you place inside it.FormMessagerenders that field'serror.message— and renders nothing when the field is valid.
The important one to look at is FormControl, because it explains a rule you'll hit later. It doesn't render an input itself; it forwards its attributes onto the child you give it:
function FormControl(props: React.ComponentProps<typeof Slot>) {
const { error, formItemId /* + description/message ids */ } = useFormField();
// It renders no input of its own — it forwards id + aria-* onto its child:
return <Slot id={formItemId} aria-invalid={!!error} {...props} />;
}Because it's a Slot, FormControl hands those props — including a ref — down to its single child. Native Input and Textarea accept a ref already. A custom input does not unless you make it, which is the gotcha in Custom inputs below.
A real form, start to finish
app/components/admission-form.tsx is the template to copy. The recipe never changes: co-locate the schema and its inferred type at the top of the file, build the form with zodResolver, wrap everything in <Form {...form}>, and render one FormField per control.
Start with the schema. It lives above the component so the type and the form grow together, and it uses Zod v4 idioms:
const admissionFormSchema = z.object({
school: z.string(),
class: z.string(),
name: z.string().min(1),
// empty string OR a valid email — see the Zod v4 callout below
email: z.union([z.email(), z.literal("")]).optional(),
referralCode: z.string().optional(),
phone: z.string().min(1),
message: z.string().min(1),
});
type AdmissionFormValues = z.infer<typeof admissionFormSchema>;That single z.infer line is what makes the rest type-safe — field, defaultValues, and your submit handler are all typed off it, so a renamed field surfaces as a compile error instead of a runtime surprise.
Next, create the form. Pass the inferred type as the generic, hand zodResolver the schema, and give every field a defaultValues entry so it starts controlled:
const form = useForm<AdmissionFormValues>({
resolver: zodResolver(admissionFormSchema),
defaultValues: {
school: "", class: "senior-1", name: "",
// …a key for every remaining field, each defaulting to ""
},
});Now the markup. Wrap everything in <Form {...form}> and a native <form> whose onSubmit runs through form.handleSubmit — that's what validates against the schema and only calls your handler with parsed, typed values. Inside, render one FormField per control; read the shape of one and you've read them all: FormItem is the wrapper, FormLabel the label, FormControl the input slot, FormMessage the error line. Every control on the page is a copy of this block with a different name and a different input.
Binding inputs: native and non-native
Here's the one place people trip. A native input understands value, onChange, onBlur, name, and ref — exactly the props {...field} carries — so spreading field straight onto an Input is the whole binding. A non-native control like the shadcn Select has its own prop names (value plus onValueChange, not onChange), so spreading does nothing useful; you map field.value and field.onChange onto the control's own props by hand. The native case is the baseline FormField:
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter your full name" /> {/* native — spread straight on */}
</FormControl>
<FormMessage />
</FormItem>
)}
/>For a non-native control, only the FormControl child changes — keep the same FormItem / FormLabel / FormMessage scaffold and bind the two props explicitly:
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-12 w-full">
<SelectValue placeholder="Select School" />
</SelectTrigger>
<SelectContent>
<SelectItem value="lugazi">Lugazi Campus</SelectItem>
<SelectItem value="namugongo">Namugongo Campus</SelectItem>
<SelectItem value="bweyogerere">Bweyogerere Campus</SelectItem>
</SelectContent>
</Select>
</FormControl>The custom PhoneInput from custom-ui/phone.input.tsx is the friendly case: it exposes value/onChange and forwards a ref, so it slots into FormControl and you spread {...field} on it exactly like a native Input — no hand-mapping needed.
Custom inputs must forward a ref
When you build your own input for a form, it has to forwardRef — because FormControl's Slot pushes a ref down to the real DOM node, and a plain function component swallows it. phone.input.tsx does this, defaults the country to "UG", and coerces undefined to "" so RHF's value is always a string:
const PhoneInput = React.forwardRef<
React.ElementRef<typeof RPNInput.default>,
PhoneInputProps
>(({ className, onChange, value, ...props }, ref) => (
<RPNInput.default
ref={ref}
defaultCountry="UG"
className={cn("flex", className)}
onChange={(v) => onChange?.(v || ("" as RPNInput.Value))}
value={value || undefined}
{...props}
/>
));
PhoneInput.displayName = "PhoneInput";Forward refs into FormControl
A custom input without forwardRef placed inside FormControl triggers a React ref warning and quietly breaks focus and the label-to-input id wiring. If you write a form input, always forwardRef it.
Submit, success, and errors
The submit handler receives values that are already validated and typed — handleSubmit won't call it otherwise — so there's nothing left to parse. The admission form sends them through its API hook and reacts to the result: reset and pop a success dialog on success, fire a toast on error.
const onSubmit = (data: AdmissionFormValues) => {
createWebsiteForm(payload, {
onSuccess: () => {
form.reset(); // clear the fields
setShowSuccessDialog(true); // show the SuccessDialog
},
onError: () => toast.error("Something went wrong. Please try again."),
});
};The submit button itself needs no separate block: render a <Button type="submit"> whose disabled is gated on !form.formState.isValid || createWebsiteFormPending so it can't be double-fired, and swap its label to "Submitting..." while the request is in flight.
isValid is stale until the first validation
This useForm sets no mode, so it validates on submit by default. That means form.formState.isValid isn't accurate until a submit (or blur) has triggered validation once — the button can sit disabled while the user types a perfectly valid form. The admission form leans on this on purpose. If you want the button to go live as the user types, pass mode: "onChange" to useForm.
Zod v4 idioms, not v3
Use the top-level validators: z.email(), z.url(), z.uuid(). The v3 style z.string().email() may warn or break under Zod v4. To allow an optional-or-valid field, use a union like the schema above: z.union([z.email(), z.literal("")]).optional().
The success dialog (custom-ui/success-dialog.tsx) is a controlled component — you own its open state, the form flips it to true. Toasts come from sonner and need no setup here; a single Toaster is already mounted globally, covered in Components.
The seven-step recipe
When you sit down to build a new form, this is the whole checklist:
- Define a Zod v4
z.objectschema andtype Values = z.infer<typeof schema>at the top of the file. const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues })— give every field a default.- Wrap the UI in
<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)}>…</form></Form>. - One
FormFieldper control →FormItem/FormLabel/FormControl/FormMessage. - Native inputs: spread
{...field}.Select/PhoneInput: bindfield.value+field.onChangeexplicitly. - Gate the submit button:
disabled={!form.formState.isValid || pending}. - On success:
form.reset()and open aSuccessDialog; on error:toast.error(...).
Forms worth reading when you need a fuller example: app/components/admission-form.tsx (the gold standard), app/routes/contact-us.tsx, app/components/admission-contact-card.tsx, and app/components/sections/lugazi-admission.tsx.
Regenerating select drops the project's extra exports
ui/select.tsx ships two project-local extras beyond stock shadcn — SelectTriggerWithIcon and SelectContentWithIcon, used by the admission form's class dropdown. Re-running pnpm dlx shadcn@latest add select overwrites the file and drops them. Re-apply them after any regenerate.
Where to go next
Components
The wider UI layer: shadcn buckets, cva variants, the Slot pattern, and toasts.
The API Client
What createWebsiteForm calls under the hood when a form submits.
Styling & Theming
Tailwind v4 tokens behind every input, button, and dialog.
Adding a Page
Drop a finished form onto a real route, end to end.