Forms with react-hook-form + Zod
Forms are the highest-stakes UI in any product. Most teams roll their
own — and most rolled-their-owns leak validation logic into components,
mishandle aria-invalid, and silently drop unsubmitted state on
re-render.
helixui doesn’t ship a form engine. We ship the components
(<Field>, <TextInput>, <Select>, <Switch>, …) and the
a11y contract (every field announces its error via
aria-describedby). The state lives in
react-hook-form; validation lives in
Zod. This recipe shows how the three fit.
Install
pnpm add react-hook-form zod @hookform/resolvers(All three are peer-friendly — under 20 KB combined, gzipped.)
The pattern
'use client';
import { useForm, FormProvider, useFormContext } from 'react-hook-form';import { zodResolver } from '@hookform/resolvers/zod';import { z } from 'zod';import { Button, Card, Field, Select, Option, Stack, Switch, TextInput } from '@helixui/core';
// 1) Schema is the source of truth.const Profile = z.object({ name: z.string().min(1, 'Required'), email: z.string().email('Invalid email'), role: z.enum(['admin', 'editor', 'viewer']), notify: z.boolean(),});type ProfileValues = z.infer<typeof Profile>;
// 2) A thin wrapper around <Field> that wires the form context.function FormField({ name, label, children,}: { name: keyof ProfileValues; label: string; children: (props: { id: string; 'aria-invalid'?: boolean; 'aria-describedby'?: string }) => React.ReactNode;}) { const id = `f-${name}`; const { register, formState } = useFormContext<ProfileValues>(); const err = formState.errors[name]; return ( <Field label={label} htmlFor={id} error={err?.message as string | undefined}> {children({ id, 'aria-invalid': !!err, 'aria-describedby': err ? `${id}-err` : undefined, })} </Field> );}
// 3) The actual form.export function ProfileForm({ onSubmit }: { onSubmit: (v: ProfileValues) => Promise<void> }) { const form = useForm<ProfileValues>({ resolver: zodResolver(Profile), defaultValues: { name: '', email: '', role: 'viewer', notify: true }, mode: 'onBlur', });
const submit = form.handleSubmit(async (values) => { await onSubmit(values); });
return ( <FormProvider {...form}> <Card variant="outlined" style={{ padding: 'var(--helixui-space-6)' }}> <form onSubmit={submit}> <Stack gap={4}> <FormField name="name" label="Name"> {(p) => <TextInput {...p} {...form.register('name')} />} </FormField>
<FormField name="email" label="Email"> {(p) => <TextInput type="email" {...p} {...form.register('email')} />} </FormField>
<FormField name="role" label="Role"> {(p) => ( <Select {...p} selectedKey={form.watch('role')} onSelectionChange={(k) => form.setValue('role', String(k) as ProfileValues['role'], { shouldValidate: true })} > <Option id="admin">Admin</Option> <Option id="editor">Editor</Option> <Option id="viewer">Viewer</Option> </Select> )} </FormField>
<Stack direction="row" align="center" justify="between"> <Stack gap={0}> <span>Email notifications</span> <small style={{ color: 'var(--helixui-color-text-muted)' }}> Weekly digest + critical alerts. </small> </Stack> <Switch isSelected={form.watch('notify')} onChange={(v) => form.setValue('notify', v, { shouldValidate: true })} /> </Stack>
<Button type="submit" loading={form.formState.isSubmitting}> Save changes </Button> </Stack> </form> </Card> </FormProvider> );}What this gets you
- Single source of truth.
ProfileValuesisz.infer<typeof Profile>— the schema is the type. Rename a field; TypeScript fails until the rename is everywhere. - Validation at any boundary. Pass
Profile.parse(req.body)on the server. Same schema. No drift. - Accessible errors out of the box.
<Field>wireshtmlFor,aria-invalid, andaria-describedbyfor you when you pass them through. mode: 'onBlur'so the user isn’t yelled at while typing.
Server Actions (Next 14+)
The same schema works:
'use server';import { Profile } from '@/lib/schemas';
export async function saveProfile(_prev: unknown, formData: FormData) { const parsed = Profile.safeParse(Object.fromEntries(formData)); if (!parsed.success) return { ok: false, errors: parsed.error.flatten().fieldErrors }; await db.user.update({ data: parsed.data }); return { ok: true };}'use client';import { useActionState } from 'react';import { saveProfile } from '@/actions/save-profile';import { Form, TextInput, Button } from '@helixui/core';
export default function Page() { const [state, action] = useActionState(saveProfile, null); return ( <Form action={action}> <TextInput name="name" required /> {state && !state.ok ? <span>{state.errors?.name}</span> : null} <Button type="submit">Save</Button> </Form> );}What we don’t do
- We don’t ship a
<HelixUIForm>component that wraps RHF. The library is excellent; wrapping it would only constrain you. The 30-lineFormFieldabove is the entire integration. - We don’t ship Zod helpers. Zod is intentional about its API; we don’t paper over it.
- We don’t ship validation messages in helixui-specific copy. Your product voice is yours.
When you outgrow this
- Conform (server-action-first) is a great alternative if you live in the Next.js App Router. Same pattern, different state engine.
- Tanstack Form is the React 19-first option. The recipe above maps one-to-one.
The helixui components don’t care which form library you use. They only
ask that you pass id, aria-invalid, and aria-describedby through
when an error is present.