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
</>
</>
<>{}</>
</>
)
}"use client"
import type { } from "form-please"
import { as } from "form-please/preset-native"
import { } from "react"
import { } from "./migration-schema"
const = .(, () => [
.("name", { : "text", : "Name", : true }),
.("email", {
: "text",
: "Email",
: true,
: { : "email" },
}),
.("topic", {
: "select",
: "Topic",
: [
{ : "billing", : "Billing" },
{ : "bug", : "Bug report" },
{ : "other", : "Other" },
],
}),
.("message", {
: "textarea",
: "Message",
: true,
}),
])
export function () {
const [, ] = <<typeof >>()
const = .(, {
: { : "", : "", : "billing", : "" },
: ({ }) => (),
})
let = "Submit to see output"
if ( !== ) = .(, null, 2)
return (
<>
<. ={}>
<.>Send ticket</.>
</.>
<>{}</>
</>
)
}The rewrite moves each part of the RHF form to one place:
| React Hook Form | Form, Please |
|---|---|
resolver: zodResolver(ticketSchema) | The schema argument of kit.defineForm |
| A label, an input, and an issue element for each field | One ui.field node. The kit renders the label, the issue, aria-invalid, and aria-describedby. |
defaultValues | The 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.
-
Move all validation into the schema. RHF does not apply
registerrules, such asrequiredandvalidate, when the form has a resolver. Do this step in RHF, before you use Form, Please. -
Replace the owner. Call
kit.useFormwith an empty definition:const ticketDefinition = kit.defineForm(ticketSchema, () => [])Replace
<form>withkit.Form, and move thehandleSubmitcallback toonSubmit. GetregisterandformStatefromform.api.form.apiis the unchanged RHFUseFormReturn, andkit.Formsupplies RHFFormProvider, so the existing field markup continues to work. This step changes the form behavior, not its markup. Release it alone. -
Move the fields into the definition. Move one field or one section, and then delete its markup.
kit.Fieldsrenders all generated fields in one place. Move the fields in visual order from the top, and keep the other markup afterkit.Fields. -
Optional: replace each
useEffectthat callssetValuefor a dependent value withbeforeUpdate. 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 code | In Form, Please |
|---|---|
useForm({ resolver }) | The definition schema is the only resolver. Pass a Standard Schema, not a resolver. |
register rules, such as required and validate | Not applied, because the form has a resolver. Put the rules in the schema. |
Async defaultValues or the values option | Not available. defaultValues is a required, complete, and synchronous schema input. Mount the form after the data loads. Read Load an edit-form baseline. |
shouldUnregister: true | Not available. Form, Please sets shouldUnregister: false, so hidden fields keep their values. Read If the form uses shouldUnregister: true. |
criteriaMode | Set to "all". To show one message for each field, set errorDisplay: "first" on the kit, the form, or the field. |
mode, reValidateMode, and delayError | The same options on kit.useForm. |
Other useForm options | Not 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. |
FormProvider | kit.Form supplies it. |
useEffect and setValue for a dependent value | beforeUpdate in the definition. |
useFieldArray | ui.array for generated arrays. Raw useFieldArray calls on form.api.control still work and do not run value middleware. |