Forms
Wire eoria fields to react-hook-form or TanStack Form, with zod for the rules.
eoria ships no form library. Field handles the label, the hint, the error and the accessibility links, and every control takes a value and a change handler. That is all a form library needs, so you pick the one you like.
Both examples below build the same sign-up form from the same zod schema.
import { z } from 'zod'
export const signUpSchema = z.object({ name: z.string().min(1, 'Enter your name.'), email: z.email('Enter a valid email address.'), password: z.string().min(8, 'Use at least 8 characters.'), terms: z.literal(true, { error: 'Accept the terms to continue.' }),})
export type SignUpValues = z.input<typeof signUpSchema>What every control needs
| Control | Value | Change handler | Blur |
|---|---|---|---|
| Input, Textarea | value |
onChangeText |
onBlur |
| PasswordInput, InputGroup | value |
onChangeText |
onBlur |
| Checkbox, Switch | checked |
onCheckedChange |
none |
| Select | value |
onValueChange |
none |
| DatePicker, Slider | value |
onValueChange |
none |
Set invalid on Field, not on the control. FieldControl passes it down, and FieldError renders only while the field is invalid and has a message.
react-hook-form
npm install react-hook-form @hookform/resolvers zodReact Native inputs are not DOM inputs, so use Controller instead of register. A small wrapper keeps the form readable.
import type { ReactElement } from 'react'import { Controller, type Control, type ControllerRenderProps, type FieldPath, type FieldValues,} from 'react-hook-form'import { Field, FieldControl, FieldError, FieldLabel } from '@/components/ui/field'
type Props<T extends FieldValues, N extends FieldPath<T>> = { control: Control<T> name: N label: string children: (field: ControllerRenderProps<T, N>) => ReactElement}
export function FormField<T extends FieldValues, N extends FieldPath<T>>({ control, name, label, children,}: Props<T, N>) { return ( <Controller control={control} name={name} render={({ field, fieldState }) => ( <Field invalid={fieldState.invalid}> <FieldLabel>{label}</FieldLabel> <FieldControl>{children(field)}</FieldControl> <FieldError>{fieldState.error?.message}</FieldError> </Field> )} /> )}import { useForm } from 'react-hook-form'import { zodResolver } from '@hookform/resolvers/zod'import { Button } from '@/components/ui/button'import { Checkbox } from '@/components/ui/checkbox'import { Input } from '@/components/ui/input'import { PasswordInput } from '@/components/ui/password-input'import { VStack } from '@/components/ui/stack'import { FormField } from '@/components/form-field'import { signUpSchema, type SignUpValues } from '@/lib/sign-up-schema'
export function SignUpForm({ onSubmit }: { onSubmit: (values: SignUpValues) => Promise<void> }) { const { control, handleSubmit, formState } = useForm<SignUpValues>({ resolver: zodResolver(signUpSchema), defaultValues: { name: '', email: '', password: '', terms: false as unknown as true }, })
return ( <VStack gap={5}> <FormField control={control} name="name" label="Name"> {({ value, onChange, onBlur, ref }) => ( <Input ref={ref} value={value} onChangeText={onChange} onBlur={onBlur} /> )} </FormField> <FormField control={control} name="email" label="Email"> {({ value, onChange, onBlur, ref }) => ( <Input ref={ref} value={value} onChangeText={onChange} onBlur={onBlur} autoCapitalize="none" keyboardType="email-address" /> )} </FormField> <FormField control={control} name="password" label="Password"> {({ value, onChange, onBlur, ref }) => ( <PasswordInput ref={ref} value={value} onChangeText={onChange} onBlur={onBlur} /> )} </FormField> <FormField control={control} name="terms" label="I accept the terms"> {({ value, onChange }) => <Checkbox checked={value} onCheckedChange={onChange} />} </FormField> <Button loading={formState.isSubmitting} onPress={handleSubmit(onSubmit)}> Create account </Button> </VStack> )}Passing ref lets react-hook-form focus the first invalid field after a failed submit.
TanStack Form
npm install @tanstack/react-form zodTanStack Form reads a zod schema directly, because zod implements Standard Schema. No adapter is needed. Errors come back as issue objects, so read message from the first one.
import { useForm } from '@tanstack/react-form'import { Button } from '@/components/ui/button'import { Checkbox } from '@/components/ui/checkbox'import { Field, FieldControl, FieldError, FieldLabel } from '@/components/ui/field'import { Input } from '@/components/ui/input'import { PasswordInput } from '@/components/ui/password-input'import { VStack } from '@/components/ui/stack'import { signUpSchema, type SignUpValues } from '@/lib/sign-up-schema'
const defaultValues: SignUpValues = { name: '', email: '', password: '', terms: false as unknown as true,}
export function SignUpForm({ onSubmit }: { onSubmit: (values: SignUpValues) => Promise<void> }) { const form = useForm({ defaultValues, validators: { onSubmit: signUpSchema }, onSubmit: ({ value }) => onSubmit(value), })
return ( <VStack gap={5}> <form.Field name="email"> {(field) => { const error = field.state.meta.errors[0]?.message return ( <Field invalid={error !== undefined}> <FieldLabel>Email</FieldLabel> <FieldControl> <Input value={field.state.value} onChangeText={field.handleChange} onBlur={field.handleBlur} autoCapitalize="none" keyboardType="email-address" /> </FieldControl> <FieldError>{error}</FieldError> </Field> ) }} </form.Field>
<form.Field name="password"> {(field) => { const error = field.state.meta.errors[0]?.message return ( <Field invalid={error !== undefined}> <FieldLabel>Password</FieldLabel> <FieldControl> <PasswordInput value={field.state.value} onChangeText={field.handleChange} onBlur={field.handleBlur} /> </FieldControl> <FieldError>{error}</FieldError> </Field> ) }} </form.Field>
<form.Field name="terms"> {(field) => { const error = field.state.meta.errors[0]?.message return ( <Field invalid={error !== undefined}> <FieldLabel>I accept the terms</FieldLabel> <FieldControl> <Checkbox checked={field.state.value} onCheckedChange={(checked) => field.handleChange(checked as true)} /> </FieldControl> <FieldError>{error}</FieldError> </Field> ) }} </form.Field>
<form.Subscribe selector={(state) => state.isSubmitting}> {(isSubmitting) => ( <Button loading={isSubmitting} onPress={() => form.handleSubmit()}> Create account </Button> )} </form.Subscribe> </VStack> )}The name field follows the email field and is left out for length. validators: { onSubmit } checks on submit, which matches the auth blocks. Use onChange or onBlur for earlier feedback.
Keep fields above the keyboard
Wrap the form in KeyboardScrollView from Keyboard, and put the submit button in KeyboardFooter when it should ride above the keyboard. Both work with either library, because they only care about the focused input.
Notes
- Show errors after the first submit or after blur. An error on the first keystroke reads as a scolding.
- Keep the schema in its own file. The server can import the same rules.
termsusesz.literal(true), so the default needs a cast. Usez.boolean().refine((v) => v, 'Accept the terms to continue.')to avoid it.- A screen reader hears the error because
FieldErrorannounces it and the control’s hint changes to the error text. Neither library needs extra work for that.