Skip to content

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

Terminal window
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. ProfileValues is z.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> wires htmlFor, aria-invalid, and aria-describedby for 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:

app/actions/save-profile.ts
'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 };
}
app/profile/page.tsx
'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-line FormField above 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.