Form

A thin bridge between React Hook Form and Field. Field owns the accessibility wiring and knows nothing about any form library; this adds only state and validation.

src/components/ui/form.tsx

About

From the component's own source, where it was written.

A thin bridge between React Hook Form and this kit's Field.

The split is deliberate: Field owns the accessibility wiring — id, aria-describedby, aria-invalid — and knows nothing about any form library, so it keeps working with useState, a server action, or nothing at all. This file adds only state and validation on top.

React Hook Form was chosen on the numbers: 241M downloads a month against 10.6M for the nearest actively maintained alternative, zero runtime dependencies, and 26 releases in 2026. Formik has more downloads than TanStack Form but shipped nothing at all in 2026.

Install

npx astralyx-ui add form

Usage

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Form, FormField } from '@/components/ui/form'

const schema = z.object({ email: z.email('Enter a valid email.') })

const form = useForm({ resolver: zodResolver(schema), mode: 'onTouched' })

<Form form={form} onSubmit={save}>
  <FormField name="email" label="Email" required>
    {(field) => <Input {...field} type="email" />}
  </FormField>
  <Button type="submit">Save</Button>
</Form>

Composer

Change the props and the source below follows.

We never share it.

Props
tsx
<Form form={form} onSubmit={save}>
  <FormField name="email" label="Email" required>
    {(field) => <Input {...field} type="email" />}
  </FormField>
</Form>

Examples

Validated signup

API

Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.

Form<TFieldValues extends FieldValues, TContext = unknown, TTransformed extends FieldValues = TFieldValues>

A thin bridge between React Hook Form and this kit's Field.

Extends Omit<ComponentProps<'form'>, 'onSubmit'>.

PropTypeDefaultDescription
formUseFormReturn<TFieldValues, TContext, TTransformed>requiredThe instance from useForm(). Provided to descendants, so nested fields need no prop drilling.
onSubmit(values: TTransformed) => void | Promise<void>requiredRuns only after validation passes. The form is noValidate, so the resolver decides — browser bubbles cannot be styled.

FormField<T extends FieldValues, N extends FieldPath<T> = FieldPath<T>>

One controlled field, wired to both the form and the accessibility plumbing.

PropTypeDefaultDescription
nameNrequiredTyped against the schema, so a rename is a compile error rather than a silent no-op.
labelReactNode—Passed through to Field and FieldLabel.
descriptionReactNode—Passed through to Field and FieldLabel.
requiredboolean—Passed through to Field and FieldLabel.
classNamestring——
children( field: ControllerRenderProps<T, N> & ReturnType<typeof useFieldControl>, ) => ReactNoderequiredRender prop. Half the controls take value/onValueChange and half take checked/onChange, so mapping is the caller's job.

Behaviour

TopicDetailDescription
errorsautomaticThe message from the resolver is handed to Field, which is what flips aria-invalid and shows it. There is no second flag.