Using react-hook-form with React 19, useActionState, and Next.js 15 App Router
I’m currently working on building a comprehensive form component with React. As usual, as I typically do, I built it all by myself without reaching for a form library. And, as usual, although I kinda got where I wanted, I was not satisfied with my code and decided to opt for a ready-made form library.
After evaluating some popular form libraries, I concluded that react-hook-form
fits my needs best. But I quickly discovered a problem: out of the box, react-hook-form
is not built to work with the latest and greatest React 19 APIs like useActionState
and Next.js server-side form handling.
Finally, after some tinkering, I had an eureka moment: I don’t need to make react-hook-form
work with Next.js form actions! I can use it for client-side error handling and let React 19 and Next.js take care of any server-side validation when submitting the form.
Setting Up react-hook-form To Work With Server Actions
Before we dive deeper into how we can share the same Zod validation rules for client-side and server-side validation, let’s set up a minimal example to demonstrate how we can make use of react-hook-form
s client-side validation capabilities in tandem with React 19 form actions.
// components/my-form.tsx
"use client";
import Form from "next/form";
import { useActionState } from "react";
import { useForm } from "react-hook-form";
import { User } from "@/entities/user";
type ActionState = {
errors: Record<string, { message: string }>;
values: User;
};
export const MyForm = ({
action,
values,
}: {
action: (
initialState: ActionState,
formData: FormData
) => Promise<ActionState>;
values: User;
}) => {
const [state, formAction, isPending] = useActionState(action, {
values,
errors: {},
});
const { formState, register } = useForm({
// Initialize the form with server-side errors
errors: state.errors,
// Make sure to use `onBlur`, `onChange`, or `onTouched` NOT `onSubmit`!
mode: "onBlur",
// Initialize the form with server-side values
values: state.values,
});
return (
<Form action={formAction}>
<label>
First Name
<input
{...register("firstName", { required: "First name is required!" })}
/>
{formState.errors.firstName ? (
<p role="alert">{formState.errors.firstName.message}</p>
) : null}
</label>
<label>
Last Name
<input
{...register("lastName", { required: "Last name is required!" })}
/>
{formState.errors.lastName ? (
<p role="alert">{formState.errors.lastName.message}</p>
) : null}
</label>
<button disabled={isPending}>Submit</button>
</Form>
);
};
The key part here is how we configure useForm()
. We use the state
from the useActionState()
React 19 hook to prefill the data for errors
and values
for the react-hook-form
useForm()
hook. This is our bridge between the server-side state returned by useActionState()
and the client-side state managed by useForm()
.
Because we want to do all the server-side validation in the server action, we need to configure the validation mode of useForm()
to something other than onSubmit
. In my experience, onBlur
works best for most forms.
Now let’s take a look at how we initialize the MyForm
component in a Next.js App Router page component:
// app/page.tsx
import { MyForm } from "@/components/my-form";
import { User } from "@/entities/user";
type ActionState = {
errors: Record<string, { message: string }>;
values: User;
};
const submitForm = async (initialState: ActionState, formData: FormData) => {
"use server";
const values = {
firstName: String(formData.get("firstName") || ""),
lastName: String(formData.get("lastName") || ""),
};
// TODO: server-side validation
// Save data in a database or send it to an API.
return {
values,
errors: {},
};
};
const getData = async () => {
"use server";
// Fetch data from a database or API.
return { firstName: "John", lastName: "Doe" };
};
export default async function Home() {
const data = await getData();
return <MyForm action={submitForm} values={data} />;
}
For now, we don’t do any server-side validation in the submitForm()
action; we’ll fix this in the next step. In the getData()
server action, we can load data to prefill the form (for demo purposes, we hardcode the data in this example). Now there is nothing more left for us than fetching the data and rendering MyForm
, passing it the submitForm()
action, and the data
from getData()
.
Syncing Server-Side and Client-Side Validation with Zod
Client-side validation with react-hook-form
already works like a breeze, but a critical thing is still missing: server-side validation. To fix this, let’s use Zod to create a shared schema that we can use both for client-side and server-side validation without duplicating too much logic.
npm install zod @hookform/resolvers
After installing the necessary dependencies, let’s create a Zod schema for our User
:
// entities/user.ts
import { z } from "zod";
export const schema = z.object({
firstName: z.string().min(1, { message: "First name is required!" }),
lastName: z.string().min(1, { message: "Last name is required!" }),
});
export type User = z.infer<typeof schema>;
Next, we can use our newly create schema for client-side validation in MyForm
.
"use client";
+import { zodResolver } from "@hookform/resolvers/zod";
import Form from "next/form";
import { useActionState } from "react";
import { useForm } from "react-hook-form";
-import { User } from "@/entities/user";
+import { schema, User } from "@/entities/user";
type ActionState = {
errors: Record<string, string>;
values: User;
};
+const resolver = zodResolver(schema);
+
export const MyForm = ({
// Initialize the form with server-side values
values: state.values,
+ resolver,
});
return (
<Form action={formAction}>
<label>
First Name
- <input {...register("firstName", { required: "First name is required!" })} />
+ <input {...register("firstName")} />
{formState.errors.firstName ? (
<p role="alert">{formState.errors.firstName.message}</p>
) : null}
</label>
<label>
Last Name
- <input {...register("lastName", { required: "Last name is required!" })} />
+ <input {...register("lastName")} />
{formState.errors.lastName ? (
<p role="alert">{formState.errors.lastName.message}</p>
With that, client-side validation works as before, using our global Zod schema instead of configuring inline validation rules with register()
. The benefit of this approach is that it enables us to use the same schema for server-side validation, too:
const submitForm = async (initialState: ActionState, formData: FormData) => {
"use server";
const values = {
firstName: String(formData.get("firstName") || ""),
lastName: String(formData.get("lastName") || ""),
};
+ const { error: parseError } = schema.safeParse(values);
+ const errors: ActionState["errors"] = {};
+ for (const { path, message } of parseError?.issues || []) {
+ errors[path.join(".")] = { message };
+ }
+
// Save data in a database or send it to an API.
return {
values,
errors,
};
};
With the above changes, we now use the same validation rules for client-side and server-side code. And if there wasn’t a weird bug hiding, we could call it a day. But let’s try to save the form two times after another without changing any data, and you’ll see that, for some reason, I couldn’t figure out, the form gets cleared.
Workaround for Disappearing Data After Submitting Multiple Times
I tried several things to fix this erroneous behavior but couldn’t get behind what was causing it. However, I discovered that it only occurs when we don’t change the data before submitting. So, I ended up with the following workaround:
const submitForm = async (initialState: ActionState, formData: FormData) => {
"use server";
const values = {
firstName: String(formData.get("firstName") || ""),
lastName: String(formData.get("lastName") || ""),
+ // This is a workaround to prevent the form state from disappearing after
+ // submitting the form two or more times.
+ __timestamp: String(Date.now()),
};
const { error: parseError } = schema.safeParse(values);
const errors: ActionState["errors"] = {};
for (const { path, message } of parseError?.issues || []) {
errors[path.join(".")] = { message };
}
Because the __timestamp
updates with each form submission, the values are always fresh, preventing data from disappearing after multiple submissions.
Wrapping It Up
For now, making react-hook-form
work with the latest React 19 and Next.js 15 releases feels a bit hacky, mainly because we need a workaround to make it work when submitting a form two or multiple times. Still, I like the API of react-hook-form
, and I’m confident that the react-hook-form
team will fix those issues in a future release.