On this page

Guides

React Hook Form

Wire Luke UI fields to React Hook Form with Controller.

React Hook Form owns the form state, and Luke UI renders the controls.

Initialise the form

Call useForm with defaultValues and a resolver, and keep the result as form. React Hook Form takes the form's value types from the Zod schema, so no hand-written type repeats it.

const schema = z.object({
	email: z.email('Enter a valid email address.'),
	name: z.string().min(1, 'Enter your name.'),
});

const form = useForm({
	defaultValues: { email: '', name: '' },
	resolver: zodResolver(schema),
});

Integrate components

Wrap the fields in Luke UI's Form. Set validationBehavior="aria" so React Hook Form controls validation without browser submission blocking it. When every required field should show a clear marker, set necessityIndicator="required-label".

Wrap each control in Controller. Pass field.value, field.onChange, and field.onBlur to the control, give field.ref to inputRef, and pass field.name as the HTML name. Set isRequired on fields the schema requires. Schema validation does not set required control semantics on its own.

Pass fieldState.error?.message to errorMessage. A non-empty message marks the field invalid.

React Hook Form: Controller

import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@luke-ui/react/button';
import { Cluster } from '@luke-ui/react/cluster';
import { Form } from '@luke-ui/react/form';
import { Stack } from '@luke-ui/react/stack';
import { Text } from '@luke-ui/react/text';
import { TextInputField } from '@luke-ui/react/text-input-field';
import { Controller, useForm } from 'react-hook-form';
import * as z from 'zod';

const schema = z.object({
	email: z.email('Enter a valid email address.'),
	name: z.string().min(1, 'Enter your name.'),
});

export default () => {
	const form = useForm({
		defaultValues: { email: '', name: '' },
		resolver: zodResolver(schema),
	});

	return (
		<Stack gap="space.16" style={{ maxInlineSize: '20rem' }}>
			<Form
				necessityIndicator="required-label"
				onSubmit={form.handleSubmit(() => undefined)}
				validationBehavior="aria"
			>
				<Stack gap="space.16">
					<Controller
						control={form.control}
						name="name"
						render={({ field, fieldState }) => {
							return (
								<TextInputField
									errorMessage={fieldState.error?.message}
									inputRef={field.ref}
									isRequired
									label="Name"
									name={field.name}
									onBlur={field.onBlur}
									onChange={field.onChange}
									value={field.value}
								/>
							);
						}}
					/>
					<Controller
						control={form.control}
						name="email"
						render={({ field, fieldState }) => {
							return (
								<TextInputField
									errorMessage={fieldState.error?.message}
									inputRef={field.ref}
									isRequired
									label="Email address"
									name={field.name}
									onBlur={field.onBlur}
									onChange={field.onChange}
									type="email"
									value={field.value}
								/>
							);
						}}
					/>
					<Cluster>
						<Button type="submit">Create account</Button>
					</Cluster>
				</Stack>
			</Form>
			<Text elementType="p" isVisuallyHidden={!form.formState.isSubmitSuccessful} role="status">
				{form.formState.isSubmitSuccessful
					? `Account created for ${form.getValues('name')}.`
					: null}
			</Text>
		</Stack>
	);
};

A checkbox or switch reads its value from isSelected.

React Hook Form: Checkbox

import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@luke-ui/react/button';
import { CheckboxField } from '@luke-ui/react/checkbox-field';
import { Cluster } from '@luke-ui/react/cluster';
import { Form } from '@luke-ui/react/form';
import { Stack } from '@luke-ui/react/stack';
import { Text } from '@luke-ui/react/text';
import { Controller, useForm } from 'react-hook-form';
import * as z from 'zod';

const schema = z.object({
	terms: z.boolean().refine((accepted) => accepted, {
		error: 'Accept the terms before you continue.',
	}),
});

export default () => {
	const form = useForm({
		defaultValues: { terms: false },
		resolver: zodResolver(schema),
	});

	return (
		<Stack gap="space.16" style={{ maxInlineSize: '20rem' }}>
			<Form
				necessityIndicator="required-label"
				onSubmit={form.handleSubmit(() => undefined)}
				validationBehavior="aria"
			>
				<Stack gap="space.16">
					<Controller
						control={form.control}
						name="terms"
						render={({ field, fieldState }) => {
							return (
								<CheckboxField
									errorMessage={fieldState.error?.message}
									inputRef={field.ref}
									isRequired
									isSelected={field.value}
									label="I accept the terms of service"
									name={field.name}
									onBlur={field.onBlur}
									onChange={field.onChange}
								/>
							);
						}}
					/>
					<Cluster>
						<Button type="submit">Continue</Button>
					</Cluster>
				</Stack>
			</Form>
			<Text elementType="p" isVisuallyHidden={!form.formState.isSubmitSuccessful} role="status">
				{form.formState.isSubmitSuccessful ? 'Terms accepted.' : null}
			</Text>
		</Stack>
	);
};

TextInputField, CheckboxField, and SwitchField render a label, description, and error message around the control, so inputRef is what reaches the input underneath. For SelectField, pass the library's ref through triggerRef to reach the trigger button. A primitive that renders the control itself, such as ComboboxInput, takes field.ref on ref.

Read Validation for controlled errors versus native validation. Read Required fields for necessity indicators.

Focus the first invalid field

React Hook Form focuses the first invalid control after a failed submission, using the ref each field registered. A field that never receives field.ref stays unfocused, and someone filling in the form gets an error message without being taken to it.

Set shouldFocusError: false on useForm to turn this off.

Submitting data

Pass form.handleSubmit(onSubmit) to Form's onSubmit. React Hook Form validates the fields before calling your handler. Prefer onSubmit over Form's action for this integration, because the library needs the submit event.

Continue learning