Skip to content

Advanced

Practical guides for building accessible, reusable, and performant forms

Explore practical patterns for reusable forms, performance, custom validation, testing, and complex workflows. For a learning path and examples organized by task, start with Guides. For function signatures and options, see the API reference.

Find a pattern for your task

I want to…Start here
Edit an existing record without losing user changesEditing saved data
Display API validation errors and allow retriesHandling server errors
Show fields based on another inputConditional fields
Avoid re-rendering a large form on every keystrokeIsolating form subscriptions
Make labels, errors, and focus work togetherAccessibility
Collect data across several stepsMulti-step forms
Share form methods with nested componentsConnect Form
Use a component library or schema validatorIntegrations
Test validation and submissionTesting forms
Submit with React 19 and Next.jsServer Actions

Upgrading an existing project? Follow the Migrate to v8 guide.

Editing saved data

Use defaultValues as the starting point for an edit form. They are cached when the form mounts: passing a different object on a later render does not load a different record. Fetch the record in a parent, handle loading and errors there, and render the form once the data is ready.

import { useForm } from "react-hook-form"
type Profile = { id: string; name: string; email: string }
type ProfileFields = Pick<Profile, "name" | "email">
export function ProfileEditor({
profile,
saveProfile,
}: {
profile: Profile
saveProfile: (values: ProfileFields) => Promise<void>
}) {
const {
register,
handleSubmit,
reset,
setError,
clearErrors,
formState: { errors, isDirty, isSubmitting },
} = useForm<ProfileFields>({
defaultValues: { name: profile.name, email: profile.email },
})
const onSubmit = async (values: ProfileFields) => {
clearErrors("root.server")
try {
await saveProfile(values)
} catch {
setError("root.server", {
message: "Could not save your changes. Please try again.",
})
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="profile-name">Name</label>
<input id="profile-name" {...register("name")} />
<label htmlFor="profile-email">Email</label>
<input id="profile-email" type="email" {...register("email")} />
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
<button type="submit" disabled={!isDirty || isSubmitting}>
{isSubmitting ? "Saving…" : "Save changes"}
</button>
<button type="button" disabled={isSubmitting} onClick={() => reset()}>
Discard changes
</button>
</form>
)
}

The parent supplies saveProfile; it should reject when the request fails. This example keeps the edited values after saving. If the server returns normalized values and you want the saved record to become the new baseline, call reset(savedProfile) when that successful response arrives, outside the submit handler. See the reset rules for submission-state timing.

When switching between records, render <ProfileEditor key={profile.id} ... /> with the real props to start a fresh form for each record. Decide whether to prompt about unsaved changes before switching.

For background refetches of the same record, choose whether to replace the user's edits. A deliberate reset(freshValues) replaces all values and establishes a new baseline. reset(freshValues, { keepDirtyValues: true }) updates untouched fields while preserving edited fields; subscribe to formState.dirtyFields when using this option. Do not run an unconditional reset on every render or on every recreated data object.

If the form itself owns the initial request, async default values are another option. Handle a rejected request, show loading feedback, and remember that this initializer runs once per mount. Use the reactive values option when external data should continue updating the form, with an explicit policy for preserving user edits.


Handling server errors

Client validation provides quick feedback. The server must also validate submitted values. Use setError for a specific field, and root.server for a request failure that does not belong to one input.

This example expects POST /api/profile to return a successful HTTP status after saving, or HTTP 422 with { "errors": { "email": "This email is already in use." } }. Implement that endpoint in your application and adjust the response mapping to its contract.

import { useState } from "react"
import { useForm } from "react-hook-form"
type FormValues = { email: string }
export default function EmailForm() {
const [saved, setSaved] = useState(false)
const {
register,
handleSubmit,
setError,
clearErrors,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ defaultValues: { email: "" } })
const onSubmit = async (values: FormValues) => {
setSaved(false)
clearErrors("root.server")
try {
const response = await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
})
if (response.status === 422) {
const result: { errors?: { email?: string } } = await response.json()
if (typeof result.errors?.email === "string") {
setError(
"email",
{ type: "server", message: result.errors.email },
{ shouldFocus: true }
)
return
}
}
if (!response.ok) throw new Error("Save failed")
setSaved(true)
} catch {
setError("root.server", {
type: "server",
message: "We couldn't save your email. Please try again.",
})
}
}
return (
<form noValidate onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email", {
required: "Enter your email address.",
onChange: () => setSaved(false),
})}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
{saved && <p role="status">Email saved.</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving…" : "Save email"}
</button>
</form>
)
}

Return the request promise from your submit handler, or await it as above, so isSubmitting covers the request. fetch does not reject on HTTP error responses, so check response.ok. Keep the inputs available after failure so the user can correct a value and retry.

shouldFocus needs a registered input ref. Keep that input enabled if you want to focus it after a server error. Only map known field names from the response, rather than treating every server key as a valid form path.

This example sets the success message only after a successful HTTP response. React Hook Form cannot interpret your API response: catching a failed request without reporting an error can leave the handler looking successful. Check the response and surface failures explicitly.


Conditional fields

Use useWatch to show an input based on another value. Decide what should happen to its data when it disappears: retain it for a later step, or remove it from the submission.

import { useForm, useWatch } from "react-hook-form"
type FormValues = { accountType: "personal" | "business"; company?: string }
export default function AccountForm() {
const {
control,
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
defaultValues: { accountType: "personal" },
shouldUnregister: true,
})
const accountType = useWatch({ control, name: "accountType" })
return (
<form noValidate onSubmit={handleSubmit((values) => console.log(values))}>
<label htmlFor="account-type">Account type</label>
<select id="account-type" {...register("accountType")}>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
{accountType === "business" && (
<div>
<label htmlFor="company">Company name</label>
<input
id="company"
aria-invalid={!!errors.company}
aria-describedby={errors.company ? "company-error" : undefined}
{...register("company", { required: "Enter your company name." })}
/>
{errors.company && (
<p id="company-error" role="alert">
{errors.company.message}
</p>
)}
</div>
)}
<button type="submit">Continue</button>
</form>
)
}

Here shouldUnregister: true removes the company value when its input unmounts. Switching back to a business account starts that field again. The default, false, retains values after unmounting, which can be useful in a wizard. Hiding an input with CSS leaves it mounted and does not unregister it.

With a schema resolver, express the same condition in the schema: require company only for business accounts. Unregistering an input does not rewrite your schema. For reordered field arrays, review the Controller rules before applying shouldUnregister, because reordering can unmount and remount inputs.


Isolating form subscriptions

Subscribe where a value is displayed. Calling watch() at the top of a large form makes that component re-render for every value change. Move small reactive pieces into child components so a counter or error message can update without re-rendering the entire form through a root subscription.

NeedAPI
Render a field value or a derived previewuseWatch in the consuming component
Render errors, dirty state, or submission stateuseFormState near that UI
Read values once in an event handlergetValues
Observe changes without rendering UIsubscribe, with cleanup
import { useForm, useFormState, useWatch, type Control } from "react-hook-form"
type FormValues = { biography: string }
function BiographyFeedback({ control }: { control: Control<FormValues> }) {
const biography = useWatch({ control, name: "biography" })
const { errors } = useFormState({ control, name: "biography" })
return (
<div>
<p id="biography-count">{biography.length} / 200 characters</p>
<p id="biography-error" role="alert">
{errors.biography?.message}
</p>
</div>
)
}
export default function BiographyForm() {
const { register, control, handleSubmit } = useForm<FormValues>({
defaultValues: { biography: "" },
mode: "onBlur",
})
return (
<form onSubmit={handleSubmit((values) => console.log(values))}>
<label htmlFor="biography">Biography</label>
<textarea
id="biography"
aria-describedby="biography-count biography-error"
{...register("biography", {
maxLength: { value: 200, message: "Use 200 characters or fewer." },
})}
/>
<BiographyFeedback control={control} />
<button type="submit">Save biography</button>
</form>
)
}

Only BiographyFeedback subscribes to biography changes. The parent can still re-render for other React reasons; this pattern removes the subscription that would otherwise update it on each keystroke. Measure before adding memoization, and use mode: "onChange" only when validation on every change is useful to the user. For context-based forms, see FormProvider performance.


Accessibility (A11y)

Give every input a visible label, describe errors in text, and connect the error message to its input. Keep the registered ref attached so React Hook Form can focus the first invalid field on submission.

import { useForm } from "react-hook-form"
type FormValues = { name: string }
export default function AccessibleForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({ defaultValues: { name: "" } })
return (
<form noValidate onSubmit={handleSubmit((values) => console.log(values))}>
<label htmlFor="name">Name</label>
<p id="name-hint">Use the name you want displayed on your profile.</p>
<input
id="name"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-hint name-error" : "name-hint"}
{...register("name", {
required: "Enter your name.",
maxLength: { value: 30, message: "Use 30 characters or fewer." },
})}
/>
{errors.name && (
<p id="name-error" role="alert">
{errors.name.message}
</p>
)}
<button type="submit">Save profile</button>
</form>
)
}

noValidate disables the browser's validation messages so this example consistently uses the messages registered with React Hook Form. It does not disable React Hook Form's validation.

aria-invalid exposes the invalid state, aria-describedby connects hints and errors, and role="alert" announces a newly displayed error. The exact announcement varies by browser and assistive technology. Do not rely on color alone, and test keyboard navigation and error focus in your actual UI. For custom controlled inputs, forward field.ref to the focusable element.


Wizard Form / Funnel

For steps that stay mounted under one parent, a single useForm instance with FormProvider can share values across the steps. Validate the current step with trigger before advancing. Use separate forms and shared storage when each step is a different route or needs its own submission boundary.

The route-based example below uses little state machine. React context, a parent component, or your existing state library can also hold the draft; an additional library is optional. Initialize each step from the stored draft so going back restores the user's previous answers.

Step 1: Set up your routes and store.

import { BrowserRouter, Routes, Route } from "react-router-dom"
import { createStore } from "little-state-machine"
import Step1 from "./Step1"
import Step2 from "./Step2"
import Result from "./Result"
createStore({
data: {
firstName: "",
lastName: "",
},
})
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Step1 />} />
<Route path="/step2" element={<Step2 />} />
<Route path="/result" element={<Result />} />
</Routes>
</BrowserRouter>
)
}

Step 2: Merge each step into the draft, then navigate to the next page.

// updateAction.js
export default function updateAction(state, payload) {
return { ...state, data: { ...state.data, ...payload } }
}
import { useForm } from "react-hook-form"
import { useNavigate } from "react-router-dom"
import { useStateMachine } from "little-state-machine"
import updateAction from "./updateAction"
export default function Step1() {
const { actions, state } = useStateMachine({ actions: { updateAction } })
const { register, handleSubmit } = useForm({ defaultValues: state.data })
const navigate = useNavigate()
const onSubmit = (data) => {
actions.updateAction(data)
navigate("/step2")
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("firstName")} />
<input {...register("lastName")} />
<input type="submit" />
</form>
)
}

Step 3: Make your final submission with all the data in the store or display the resulting data.

import { useStateMachine } from "little-state-machine"
export default function Result() {
const { state } = useStateMachine()
return <pre>{JSON.stringify(state, null, 2)}</pre>
}

Each step saves its validated values before navigation. The imported updateAction should merge those values into state.data, preserving answers from other steps. On the final step, validate the complete payload again before saving it on the server. If the draft must survive a reload, choose an explicit persistence strategy and avoid storing sensitive fields such as passwords in browser storage.


Smart Form Component

The idea here is that you can easily compose your form using input components. We are going to create a Form component to automatically collect form data.

import { Form, Input, Select } from "./Components"
export default function App() {
const onSubmit = (data) => console.log(data)
return (
<Form onSubmit={onSubmit}>
<Input name="firstName" />
<Input name="lastName" />
<Select name="gender" options={["female", "male", "other"]} />
{/* not routed through the custom Input — it has no `name`, so `Form` won't inject `register` for it */}
<input type="submit" value="Submit" />
</Form>
)
}

Let's have a look at what is inside each of these components.

</> Form

The Form component's responsibility is to inject all react-hook-form methods into the child component.

import { Children, createElement } from "react"
import { useForm } from "react-hook-form"
export default function Form({ defaultValues, children, onSubmit }) {
const methods = useForm({ defaultValues })
const { handleSubmit } = methods
return (
<form onSubmit={handleSubmit(onSubmit)}>
{Children.map(children, (child) => {
return child.props.name
? createElement(child.type, {
...{
...child.props,
register: methods.register,
key: child.props.name,
},
})
: child
})}
</form>
)
}

</> Input / Select

The responsibility of these input components is to register themselves with react-hook-form.

export function Input({ register, name, ...rest }) {
return <input {...register(name)} {...rest} />
}
export function Select({ register, options, name, ...rest }) {
return (
<select {...register(name)} {...rest}>
{options.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
)
}

With the Form component injecting react-hook-form's props into the child component, you can easily create and compose complex forms in your app.


Error Messages

Error messages are visual feedback to our users when there are issues with their inputs. React Hook Form provides an errors object to let you retrieve errors easily. There are several different ways to improve error presentation on the screen.

  • Register

    You can simply pass the error message to register, via the message attribute of the validation rule object, like this:

    <input {...register('test', { maxLength: { value: 2, message: "error message" } })} />

  • Optional Chaining

    The ?. optional chaining operator permits reading the errors object without worrying about causing another error due to null or undefined.

    errors?.firstName?.message

  • Lodash get

    If your project is using lodash, then you can leverage the lodash get function. Eg:

    get(errors, 'firstName.message')


Connect Form

When we are building forms, there are times when our input lives inside deeply nested component trees; that's when useFormContext comes in handy. However, we can further improve the developer experience by creating a ConnectForm component and leveraging React's renderProps. The benefit is that you can connect your inputs to React Hook Form much more easily.

import { FormProvider, useForm, useFormContext } from "react-hook-form"
export const ConnectForm = ({ children }) => {
const methods = useFormContext()
return children(methods)
}
export const DeepNest = () => (
<ConnectForm>
{({ register }) => <input {...register("deepNestedInput")} />}
</ConnectForm>
)
export const App = () => {
const methods = useForm()
return (
<FormProvider {...methods}>
<form>
<DeepNest />
</form>
</FormProvider>
)
}

FormProvider Performance

React Hook Form's FormProvider is built upon React's Context API. It solves the problem where data is passed through the component tree without having to pass props down manually at every level. This also causes the component tree to trigger a re-render when React Hook Form triggers a state update; however, you can still optimize your application if required, as shown in the example below.

Note: Using React Hook Form's Devtools alongside FormProvider can cause performance issues in some situations. Before diving deep in performance optimizations, consider this bottleneck first.

import { memo } from "react"
import { useForm, FormProvider, useFormContext } from "react-hook-form"
// we can use memo to prevent re-renders except when the isDirty state changes
const NestedInput = memo(
({ register, formState: { isDirty } }) => (
<div>
<input {...register("test")} />
{isDirty && <p>This field is dirty</p>}
</div>
),
(prevProps, nextProps) =>
prevProps.formState.isDirty === nextProps.formState.isDirty
)
export const NestedInputContainer = ({ children }) => {
const methods = useFormContext()
return <NestedInput {...methods} />
}
export default function App() {
const methods = useForm()
const onSubmit = (data) => console.log(data)
console.log(methods.formState.isDirty) // make sure `formState` is read before rendering to enable the Proxy
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<NestedInputContainer />
<input type="submit" />
</form>
</FormProvider>
)
}

Controlled mixed with Uncontrolled Components

React Hook Form embraces uncontrolled components but is also compatible with controlled components. Some components in UI libraries like MUI and Antd (e.g. Select, Checkbox) do not expose a native input ref and must be wrapped with Controller. Simpler components like Input forward the ref and can be used directly with {...register}. React Hook Form also optimizes re-rendering for controlled components. Here is an example that combines both patterns with validation.

import { Input, Select, MenuItem } from "@mui/material"
import { useForm, Controller } from "react-hook-form"
const defaultValues = {
select: 10,
input: "",
}
function App() {
const { handleSubmit, reset, control, register } = useForm({
defaultValues,
})
const onSubmit = (data) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Controller's own defaultValue is ignored here — useForm's defaultValues takes precedence for the same field */}
<Controller
render={({ field }) => (
<Select {...field}>
<MenuItem value={10}>Ten</MenuItem>
<MenuItem value={20}>Twenty</MenuItem>
</Select>
)}
control={control}
name="select"
/>
<Input {...register("input")} />
<button type="button" onClick={() => reset({ ...defaultValues })}>
Reset
</button>
<input type="submit" />
</form>
)
}

Custom Hook with Resolver

You can build a custom hook as a resolver. A custom hook can easily integrate with Yup/Joi/Superstruct as a validation method to be used inside a validation resolver.

  • Define a memoized validation schema (or define it outside your component if you don't have any dependencies)
  • Use the custom hook, by passing the validation schema
  • Pass the validation resolver to the useForm hook
import { useCallback } from "react"
import { useForm } from "react-hook-form"
import * as yup from "yup"
const useYupValidationResolver = (validationSchema) =>
useCallback(
async (data) => {
try {
const values = await validationSchema.validate(data, {
abortEarly: false,
})
return {
values,
errors: {},
}
} catch (errors) {
return {
values: {},
errors: errors.inner.reduce(
(allErrors, currentError) => ({
...allErrors,
[currentError.path]: {
type: currentError.type ?? "validation",
message: currentError.message,
},
}),
{}
),
}
}
},
[validationSchema]
)
const validationSchema = yup.object({
firstName: yup.string().required("Required"),
lastName: yup.string().required("Required"),
})
export default function App() {
const resolver = useYupValidationResolver(validationSchema)
const { handleSubmit, register } = useForm({ resolver })
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("firstName")} />
<input {...register("lastName")} />
<input type="submit" />
</form>
)
}

Working with virtualized lists

Imagine a scenario where you have a table of data. This table might contain hundreds or thousands of rows, and each row will have inputs. A common practice is to only render the items in the viewport; however, this causes issues as items are removed from the DOM when they are out of view and then re-added. This will cause items to reset to their default values when they re-enter the viewport.

An example is shown below using react-window.

import { memo } from "react"
import { FormProvider, useForm, useFormContext } from "react-hook-form"
import { VariableSizeList as List } from "react-window"
import AutoSizer from "react-virtualized-auto-sizer"
const items = Array.from(Array(1000).keys()).map((i) => ({
title: `List ${i}`,
quantity: Math.floor(Math.random() * 10),
}))
const WindowedRow = memo(({ index, style, data }) => {
const { register } = useFormContext()
return (
<div style={style}>
<label>{data[index].title}</label>
<input {...register(`${index}.quantity`)} />
</div>
)
})
export const App = () => {
const onSubmit = (data) => console.log(data)
const methods = useForm({ defaultValues: items })
return (
<form onSubmit={methods.handleSubmit(onSubmit)}>
<FormProvider {...methods}>
<AutoSizer>
{({ height, width }) => (
<List
height={height}
itemCount={items.length}
itemSize={() => 100}
width={width}
itemData={items}
>
{WindowedRow}
</List>
)}
</AutoSizer>
</FormProvider>
<button type="submit">Submit</button>
</form>
)
}

Testing Form

Testing is important because it prevents bugs and mistakes. It also ensures code safety when refactoring.

We recommend using testing-library, because it is simple and tests are more focused on user behavior.

Step 1: Set up your testing environment.

For a web form, use a DOM test environment such as jsdom with React Testing Library and jest-dom matchers. The examples below use Jest; adapt the setup to your test runner using the Testing Library setup guide.

npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom

Import the matchers in your setup file:

// setupTests.js
import "@testing-library/jest-dom"

Add that file and the DOM environment to your existing Jest configuration. Your project must also configure JSX or TypeScript transformation if it does not already do so.

module.exports = {
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/setupTests.js"],
// Keep your project's existing transform and other settings.
}

For React Native, use React Native Testing Library and its native setup instead of adding a browser window shim.

Additionally, you can set up eslint-plugin-testing-library and eslint-plugin-jest-dom to follow best practices and anticipate common mistakes when writing your tests.

Step 2: Create login form.

Use visible labels and native elements so tests can find inputs and buttons by their accessible names. Error messages use role="alert" so the tests can wait for validation feedback.

import { useEffect } from "react"
import { useForm } from "react-hook-form"
export default function App({ login }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitSuccessful },
reset,
} = useForm({ defaultValues: { email: "", password: "" } })
useEffect(() => {
if (isSubmitSuccessful) reset()
}, [isSubmitSuccessful, reset])
const onSubmit = async (data) => {
await login(data.email, data.password)
}
return (
<form noValidate onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="email">email</label>
<input
id="email"
{...register("email", {
required: "required",
pattern: {
value: /\S+@\S+\.\S+/,
message: "Entered value does not match email format",
},
})}
type="email"
/>
{errors.email && <span role="alert">{errors.email.message}</span>}
<label htmlFor="password">password</label>
<input
id="password"
{...register("password", {
required: "required",
minLength: {
value: 5,
message: "min length is 5",
},
})}
type="password"
/>
{errors.password && <span role="alert">{errors.password.message}</span>}
<button type="submit">SUBMIT</button>
</form>
)
}

Step 3: Write tests.

Use userEvent to type and click as a user would. Await those interactions and use findBy queries or waitFor for asynchronous validation and submission feedback.

The following criteria are what we aim to cover with the tests:

  • Test submission failure.

    We are using waitFor util and find* queries to detect submission feedback, because the handleSubmit method is executed asynchronously.

  • Test validation associated with each input.

    We are using the *ByRole method when querying different elements because that's how users recognize your UI component.

  • Test successful submission.

import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import App from "./App"
const mockLogin = jest.fn((email, password) => {
return Promise.resolve({ email, password })
})
it("should display required error when value is invalid", async () => {
const user = userEvent.setup()
mockLogin.mockClear()
render(<App login={mockLogin} />)
await user.click(screen.getByRole("button", { name: /submit/i }))
expect(await screen.findAllByRole("alert")).toHaveLength(2)
expect(mockLogin).not.toHaveBeenCalled()
})
it("should display matching error when email is invalid", async () => {
const user = userEvent.setup()
mockLogin.mockClear()
render(<App login={mockLogin} />)
await user.type(screen.getByRole("textbox", { name: /email/i }), "test")
await user.type(screen.getByLabelText("password"), "password")
await user.click(screen.getByRole("button", { name: /submit/i }))
expect(await screen.findAllByRole("alert")).toHaveLength(1)
expect(mockLogin).not.toHaveBeenCalled()
expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue("test")
expect(screen.getByLabelText("password")).toHaveValue("password")
})
it("should display min length error when password is invalid", async () => {
const user = userEvent.setup()
mockLogin.mockClear()
render(<App login={mockLogin} />)
await user.type(
screen.getByRole("textbox", { name: /email/i }),
"test@mail.com"
)
await user.type(screen.getByLabelText("password"), "pass")
await user.click(screen.getByRole("button", { name: /submit/i }))
expect(await screen.findAllByRole("alert")).toHaveLength(1)
expect(mockLogin).not.toHaveBeenCalled()
expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue(
"test@mail.com"
)
expect(screen.getByLabelText("password")).toHaveValue("pass")
})
it("should not display error when value is valid", async () => {
const user = userEvent.setup()
mockLogin.mockClear()
render(<App login={mockLogin} />)
await user.type(
screen.getByRole("textbox", { name: /email/i }),
"test@mail.com"
)
await user.type(screen.getByLabelText("password"), "password")
await user.click(screen.getByRole("button", { name: /submit/i }))
await waitFor(() => {
expect(screen.queryAllByRole("alert")).toHaveLength(0)
// reset() fires after the async login resolves and triggers a re-render;
// all post-reset assertions must be inside waitFor so they retry until
// the DOM reflects the new state.
expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue("")
expect(screen.getByLabelText("password")).toHaveValue("")
})
expect(mockLogin).toHaveBeenCalledWith("test@mail.com", "password")
})

Resolving act warning during test

If you test a component that uses react-hook-form, you might run into a warning like this, even if you didn't write any asynchronous code for that component:

Warning: An update to MyComponent inside a test was not wrapped in act(...)

import { useForm } from "react-hook-form"
export default function App() {
const { register, handleSubmit } = useForm({
mode: "onChange",
})
const onSubmit = (data) => {}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("answer", {
required: true,
})}
/>
<button type="submit">SUBMIT</button>
</form>
)
}
import { render, screen } from "@testing-library/react"
import App from "./App"
it("should have a submit button", () => {
render(<App />)
expect(screen.getByText("SUBMIT")).toBeInTheDocument()
})

In this example, there is a simple form without any apparent async code, and the test merely renders the component and checks for the presence of a button. However, it still logs the warning about updates not being wrapped in act().

This is because react-hook-form internally uses asynchronous validation handlers. In order to compute the formState, it has to initially validate the form, which is done asynchronously and results in another render. That update happens after the test function returns, which triggers the warning.

To solve this, wait until some element from your UI appears with find* queries. Note that you must not wrap your render() calls in act(). You can read more about wrapping things in act unnecessarily here.

import { render, screen } from "@testing-library/react"
import App from "./App"
it("should have a submit button", async () => {
render(<App />)
expect(await screen.findByText("SUBMIT")).toBeInTheDocument()
// Now that the UI was awaited until the async behavior was complete,
// you can keep asserting with `get*` queries.
expect(screen.getByRole("textbox")).toBeInTheDocument()
})

Transform and Parse

Native inputs return values in string format unless valueAsNumber or valueAsDate is used; you can read more in this section. However, it's not perfect, as we still have to deal with NaN or null values. Therefore, it's better to handle transformations at the custom hook level. In the following example, we use the Controller to handle input and output transformations. You can also achieve a similar result with a custom register.

Important: spread field onto the input before overriding onChange/value, so onBlur, name, and ref are still forwarded — without ref, RHF can't focus the input on a validation error, and without onBlur, isTouched never updates.

import { Controller } from "react-hook-form"
const ControllerPlus = ({ control, transform, name, defaultValue }) => (
<Controller
defaultValue={defaultValue}
control={control}
name={name}
render={({ field }) => (
<input
{...field}
onChange={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
)}
/>
)
// usage below:
<ControllerPlus
transform={{
input: (value) => (isNaN(value) || value === 0 ? "" : value.toString()),
output: (e) => {
const output = parseInt(e.target.value, 10)
return isNaN(output) ? 0 : output
},
}}
control={control}
name="number"
defaultValue=""
/>

Server Actions / useActionState

Next.js Server Actions and React's useActionState work well together, but combining them with React Hook Form can look convoluted if you try to sync state between them with useEffect. You don't need to: useActionState returns a plain dispatch function, so call the dispatcher inside startTransition after handleSubmit has passed client-side validation. React requires this Action context to track pending state correctly.

actions.js: a Server Action that receives the previous state and the validated form data.

"use server"
// Replace these imports with your application's authentication and database modules.
import { requireUser } from "./auth"
import { db } from "./db"
export async function updateProfile(previousState, data) {
if (typeof data?.name !== "string" || !data.name.trim()) {
return { success: false, error: "Enter your name." }
}
try {
const user = await requireUser()
await db.user.update({
where: { id: user.id },
data: { name: data.name.trim() },
})
return { success: true }
} catch {
return { success: false, error: "Something went wrong. Please try again." }
}
}

profile-form.jsx: handleSubmit validates the client form, then dispatches the values inside a Transition. The action receives a plain object rather than FormData; those values are still untrusted and must be validated and authorized on the server, as above. The requireUser helper must authenticate the request and reject unauthenticated access.

"use client"
import { useForm } from "react-hook-form"
import { startTransition, useActionState } from "react"
import { updateProfile } from "./actions"
export default function ProfileForm() {
const [state, submitAction, isPending] = useActionState(updateProfile, null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm()
return (
<form
onSubmit={handleSubmit((data) => {
startTransition(() => submitAction(data))
})}
>
<input {...register("name", { required: "Name is required" })} />
{errors.name && <span role="alert">{errors.name.message}</span>}
<button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
{state?.success && <p>Profile updated.</p>}
{state?.error && <p role="alert">{state.error}</p>}
</form>
)
}

isPending and state come straight from useActionState and are read directly in the JSX above, so there's nothing to synchronize: isPending replaces a manual loading useState, and state.success / state.error replace a manual result useState. The Server Action only runs once handleSubmit has confirmed the form is valid. This manually dispatched pattern needs client JavaScript; use a native form Action when submission before hydration is required. See React's useActionState guidance.

If the server needs to report field-level errors (for example, "email already in use"), map them onto React Hook Form's error state with setError. This is a legitimate use of useEffect, since you are synchronizing an external state source (the action's returned state) into the form rather than working around missing render-time state:

useEffect(() => {
if (state?.fieldErrors) {
for (const [name, message] of Object.entries(state.fieldErrors)) {
setError(name, { type: "server", message })
}
}
}, [state, setError])

If you don't need useActionState's pending/result state at all, React Hook Form's own Form component can submit directly to a Server Action through its action prop, with progressive enhancement handled for you.


Registering Opaque Types

Forms often store richer values than plain strings and numbers — a date picker backed by Day.js's Dayjs, a price field backed by Decimal.js's Decimal, or a range backed by Luxon's DateTime. React Hook Form's type helpers (Path, DeepPartial, FieldErrors, and others) walk every property of TFieldValues to build up field-path inference, and by default that walk recurses into those classes too — their internal properties, getters, and methods included.

For a small form this is harmless. For a large form, or one nested a few levels deep, that extra recursion can be expensive enough to slow down editor type-checking, and in the worst case trip TypeScript's "Type instantiation is excessively deep and possibly infinite" diagnostic.

import { useForm } from "react-hook-form"
import type { Dayjs } from "dayjs"
type BookingForm = {
guest: {
name: string
address: {
line1: string
line2: string
city: string
}
}
stay: {
checkIn: Dayjs
checkOut: Dayjs
}
}
// Without registering Dayjs, Path<BookingForm> recurses into every
// property Dayjs exposes (its internal $d, $y, $M, ..., and every
// method) on top of the form's own fields.
const { register } = useForm<BookingForm>()

Register Dayjs as an opaque type once, in a .d.ts file that's part of your program (or any file the module augmentation can reach):

import type { Dayjs } from "dayjs"
declare module "react-hook-form" {
interface OpaqueTypes {
dayjs: Dayjs
}
}

With that in place, stay.checkIn and stay.checkOut are treated as single leaf values — the same way Date already is — instead of objects to traverse:

const { register, control } = useForm<BookingForm>()
// "stay.checkIn" and "stay.checkOut" are valid, fully-inferred field
// paths; TypeScript no longer descends into Dayjs's own properties
// to compute them.
register("stay.checkIn")
register("guest.address.city")

The registration is global to your project (declaration merging on OpaqueTypes applies wherever react-hook-form's types are used), so it only needs to happen once — typically alongside your other global type declarations — even if Dayjs, Decimal, or similar rich value types show up in many different forms.

See the OpaqueTypes TypeScript reference for the type definition and further details.