Skip to content
Form, Please

Migrate from React Hook Form (RHF)

An application with many RHF forms cannot stop feature work for a migration. Migrate one form at a time. RHF forms and Form, Please forms work in the same application, and they need no shared setup.

Rewrite one form

Most forms fit in one pull request. Keep the schema and the default values. Replace the markup with a definition. Before you start, read Behavior changes.

This support ticket form uses RHF, zodResolver, and one Zod schema:

import {  } from "zod"
 
export const  = .({
	: .().(2, "Enter at least two characters"),
	: .().("Enter a valid email"),
	: .(["billing", "bug", "other"]),
	: .().(10, "Enter at least 10 characters"),
})

The two tabs show the same form before and after the rewrite:

"use client"
 
import {  } from "@hookform/resolvers/zod"
import {  } from "react"
import {  } from "react-hook-form"
import type {  } from "zod"
import {  } from "./migration-schema"
 
export function () {
	const [, ] = <.<typeof >>()
	const {
		: { ,  },
		,
		,
	} = <
		.<typeof >,
		unknown,
		.<typeof >
	>({
		: (),
		: { : "", : "", : "billing", : "" },
	})
 
	let  = "Submit to see output"
	if ( !== )  = .(, null, 2)
 
	return (
		<>
			<  ={(() => ())}>
				< ="ticket-name">Name</>
				<
					="ticket-name"
					="ticket-name-issue"
					={. !== }
					{...("name")}
				/>
				< ="ticket-name-issue">{.?.}</>
 
				< ="ticket-email">Email</>
				<
					="ticket-email"
					="email"
					="ticket-email-issue"
					={. !== }
					{...("email")}
				/>
				< ="ticket-email-issue">{.?.}</>
 
				< ="ticket-topic">Topic</>
				< ="ticket-topic" {...("topic")}>
					< ="billing">Billing</>
					< ="bug">Bug report</>
					< ="other">Other</>
				</>
 
				< ="ticket-message">Message</>
				<
					="ticket-message"
					="ticket-message-issue"
					={. !== }
					{...("message")}
				/>
				< ="ticket-message-issue">{.?.}</>
 
				< ="submit" ={}>
					Send ticket
				</>
			</>
			<>{}</>
		</>
	)
}

The rewrite moves each part of the RHF form to one place:

React Hook FormForm, Please
resolver: zodResolver(ticketSchema)The schema argument of kit.defineForm
A label, an input, and an issue element for each fieldOne ui.field node. The kit renders the label, the issue, aria-invalid, and aria-describedby.
defaultValuesThe same defaultValues in kit.useForm
handleSubmit(callback)The onSubmit option. value is the schema output.
<form noValidate>kit.AutoForm, which sets noValidate

Compare the result

Switch between the two versions. Submit each form empty, and then submit valid values. Both versions show the same schema issues and the same output.

Submit to see output

Why migrate one form at a time

Each form has its own form binding. You can review, merge, and release each rewritten form alone. The other forms of the application do not change.

The rewritten form has no field markup. The type checker verifies each path against the schema, and the kit supplies the accessible markup. The kit wraps each design-system control one time, so the next form costs less.

Write a test for the RHF form before the rewrite. Find the controls by their labels, and check the issues and the submit output. The same test must pass after the rewrite without changes:

// Write this test for the React Hook Form version first. Run it without
// changes after each migration step.
const versions = [
	["React Hook Form", TicketFormBefore],
	["in progress", TicketFormInProgress],
	["Form, Please", TicketFormAfter],
] as const
 
test.each(versions)("%s shows schema issues", async (_name, TicketForm) => {
	const user = userEvent.setup()
	render(<TicketForm />)
 
	await user.click(screen.getByRole("button", { name: "Send ticket" }))
 
	expect(
		(await screen.findAllByText("Enter at least two characters")).length,
	).toBeGreaterThan(0)
	expect(
		screen.getAllByText("Enter at least 10 characters").length,
	).toBeGreaterThan(0)
})
 
test.each(versions)(
	"%s submits the schema output",
	async (_name, TicketForm) => {
		const user = userEvent.setup()
		render(<TicketForm />)
 
		await user.type(screen.getByLabelText(/^Name/), "Ada Lovelace")
		await user.type(screen.getByLabelText(/^Email/), "ada@example.com")
		await user.selectOptions(screen.getByLabelText(/^Topic/), "bug")
		await user.type(
			screen.getByLabelText(/^Message/),
			"The export button fails.",
		)
		await user.click(screen.getByRole("button", { name: "Send ticket" }))
 
		expect(await screen.findByText(/"topic": "bug"/)).toBeTruthy()
		expect(screen.getByText(/"name": "Ada Lovelace"/)).toBeTruthy()
	},
)

Advanced use

Migrate a large form in steps

Use steps when a form is too large for one pull request, or when other people change the form at the same time. kit.useForm creates its own RHF instance, so the first step replaces the owner of the form state. The next steps move the fields.

  1. Move all validation into the schema. RHF does not apply register rules, such as required and validate, when the form has a resolver. Do this step in RHF, before you use Form, Please.

  2. Replace the owner. Call kit.useForm with an empty definition:

    const ticketDefinition = kit.defineForm(ticketSchema, () => [])

    Replace <form> with kit.Form, and move the handleSubmit callback to onSubmit. Get register and formState from form.api. form.api is the unchanged RHF UseFormReturn, and kit.Form supplies RHF FormProvider, so the existing field markup continues to work. This step changes the form behavior, not its markup. Release it alone.

  3. Move the fields into the definition. Move one field or one section, and then delete its markup. kit.Fields renders all generated fields in one place. Move the fields in visual order from the top, and keep the other markup after kit.Fields.

  4. Optional: replace each useEffect that calls setValue for a dependent value with beforeUpdate. Read Value middleware.

This form is at step 3. The name and email fields moved. The topic and message fields still use RHF markup:

// The definition contains only the fields that moved.
const ticketDefinition = kit.defineForm(ticketSchema, (ui) => [
	ui.field("name", { control: "text", label: "Name", required: true }),
	ui.field("email", {
		control: "text",
		label: "Email",
		required: true,
		props: { type: "email" },
	}),
])
 
export function TicketFormInProgress() {
	const [saved, setSaved] = useState<FormOutput<typeof ticketSchema>>()
	const form = kit.useForm(ticketDefinition, {
		defaultValues: { name: "", email: "", topic: "billing", message: "" },
		onSubmit: ({ value }) => setSaved(value),
	})
	// The markup that did not move keeps its React Hook Form code.
	const {
		formState: { errors },
		register,
	} = form.api
 
	let output = "Submit to see output"
	if (saved !== undefined) output = JSON.stringify(saved, null, 2)
 
	return (
		<>
			<kit.Form form={form}>
				<kit.Fields />
 
				<label htmlFor="ticket-topic">Topic</label>
				<select id="ticket-topic" {...register("topic")}>
					<option value="billing">Billing</option>
					<option value="bug">Bug report</option>
					<option value="other">Other</option>
				</select>
 
				<label htmlFor="ticket-message">Message</label>
				<textarea
					id="ticket-message"
					aria-describedby="ticket-message-issue"
					aria-invalid={errors.message !== undefined}
					{...register("message")}
				/>
				<p id="ticket-message-issue">{errors.message?.message}</p>
 
				<kit.Submit>Send ticket</kit.Submit>
			</kit.Form>
			<pre>{output}</pre>
		</>
	)
}

The test from Why migrate one form at a time passes for this version too. Each field that stays outside the definition supplies its own label, issue output, and focus behavior. Read You keep the React Hook Form API.

Wrap custom controls first

A form that uses design-system controls needs those controls in the kit. Register each control with defineControl one time. Start with the controls that the first form uses, and add other controls when the next forms need them. Read Form kits.

If the form uses shouldUnregister: true

In React Hook Form, shouldUnregister: true removes a field value when the field unmounts. This one flag has three results. Form, Please gives each result its own tool, so the definition shows which results the form uses.

Do not submit the hidden value. Before, RHF removed the key from the handleSubmit data. Now, a schema transform removes it:

	.transform(({ companyName, ...account }) => {
		if (account.accountType === "company") return { ...account, companyName }
		return account
	})

Do not validate the hidden value. Before, RHF did not validate an unregistered field. Now, the schema uses the same condition as visible:

	.refine(
		(value) =>
			value.accountType !== "company" || value.companyName.trim() !== "",
		{ message: "Enter the company name", path: ["companyName"] },
	)

Clear the value when the field is hidden. Before, the field was empty when it mounted again. Now, whenHidden resets it in the same managed update:

const accountDefinition = kit.defineForm(accountSchema, (ui) => [
	ui.field("accountType", {
		control: "radio",
		label: "Account type",
		options: accountTypes,
	}),
	ui.field("companyName", {
		control: "text",
		label: "Company name",
		visible: (values) => values.accountType === "company",
		whenHidden: "reset",
	}),
])

Use all three tools to get the full RHF behavior. whenHidden alone keeps the key in the submitted values and in validation. Read Reset a hidden field.

Reference

Behavior changes

React Hook Form codeIn Form, Please
useForm({ resolver })The definition schema is the only resolver. Pass a Standard Schema, not a resolver.
register rules, such as required and validateNot applied, because the form has a resolver. Put the rules in the schema.
Async defaultValues or the values optionNot available. defaultValues is a required, complete, and synchronous schema input. Mount the form after the data loads. Read Load an edit-form baseline.
shouldUnregister: trueNot available. Form, Please sets shouldUnregister: false, so hidden fields keep their values. Read If the form uses shouldUnregister: true.
criteriaModeSet to "all". To show one message for each field, set errorDisplay: "first" on the kit, the form, or the field.
mode, reValidateMode, and delayErrorThe same options on kit.useForm.
Other useForm optionsNot available. See useForm for the complete list.
handleSubmit(onValid)The onSubmit option. A direct form.api.handleSubmit call does not call onSubmit.
<form noValidate>kit.Form sets noValidate.
FormProviderkit.Form supplies it.
useEffect and setValue for a dependent valuebeforeUpdate in the definition.
useFieldArrayui.array for generated arrays. Raw useFieldArray calls on form.api.control still work and do not run value middleware.

Next steps