React Hook Form
next-safe-action has a first-party adapter for React Hook Form that provides seamless integration between validated server actions and form state management.
Installation
npm install next-safe-action react-hook-form @hookform/resolvers @next-safe-action/adapter-react-hook-formQuick start
Define the action
Create a standard server action with an input schema:
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
const createUserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.coerce.number().min(18, "Must be at least 18"),
});
export const createUser = actionClient
.inputSchema(createUserSchema)
.action(async ({ parsedInput }) => {
const user = await db.user.create({ data: parsedInput });
return { user };
});Create the form component
Use the useHookFormAction hook from the adapter to connect your action with React Hook Form:
"use client";
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { createUser } from "./actions";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.coerce.number().min(18, "Must be at least 18"),
});
export function CreateUserForm() {
const { form, action, handleSubmitWithAction, resetFormAndAction } =
useHookFormAction(createUser, zodResolver(schema), {
formProps: {
defaultValues: {
name: "",
email: "",
age: 18,
},
},
actionProps: {
onSuccess: ({ data }) => {
alert(`Created user: ${data.user.name}`);
resetFormAndAction();
},
},
});
return (
<form onSubmit={handleSubmitWithAction}>
<div>
<label>Name</label>
<input {...form.register("name")} />
{form.formState.errors.name && (
<p>{form.formState.errors.name.message}</p>
)}
</div>
<div>
<label>Email</label>
<input {...form.register("email")} />
{form.formState.errors.email && (
<p>{form.formState.errors.email.message}</p>
)}
</div>
<div>
<label>Age</label>
<input type="number" {...form.register("age")} />
{form.formState.errors.age && (
<p>{form.formState.errors.age.message}</p>
)}
</div>
{action.result.serverError && (
<p>{action.result.serverError}</p>
)}
<button type="submit" disabled={action.isPending}>
{action.isPending ? "Creating..." : "Create User"}
</button>
</form>
);
}How it works
The adapter bridges two concerns:
- Client-side validation: React Hook Form validates the form using the resolver (zodResolver, valibotResolver, etc.) for instant feedback
- Server-side validation: next-safe-action validates the same data on the server for security
When the form is submitted:
- React Hook Form validates client-side first
- If valid, the data is sent to the server action
- Server-side validation errors are automatically mapped back to the form fields via react-hook-form's
errorsprop
Package entry points
The adapter is split into two entry points:
| Entry point | Exports | Environment |
|---|---|---|
@next-safe-action/adapter-react-hook-form | mapToHookFormErrors, ErrorMapperProps | Server & Client |
@next-safe-action/adapter-react-hook-form/hooks | useHookFormAction, useHookFormOptimisticAction, useHookFormActionErrorMapper | Client only |
useHookFormAction
The primary hook for using safe actions with React Hook Form. It combines useAction and useForm into a single hook, automatically mapping server validation errors to form field errors.
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks";
const { form, action, handleSubmitWithAction, resetFormAndAction } =
useHookFormAction(safeAction, hookFormResolver, props?);Parameters
Prop
Type
Props
The optional props object has the following shape:
Prop
Type
Return object
Prop
Type
Example
See the Quick start section above for a full example.
useHookFormOptimisticAction
Combines useOptimisticAction and useForm into a single hook. Use this when you want optimistic UI updates — the form's result updates immediately before the server responds, then reverts if the action fails.
import { useHookFormOptimisticAction } from "@next-safe-action/adapter-react-hook-form/hooks";
const { form, action, handleSubmitWithAction, resetFormAndAction } =
useHookFormOptimisticAction(safeAction, hookFormResolver, props);Parameters
Prop
Type
Props
Same as useHookFormAction props, with the following required additions in actionProps:
Prop
Type
Return object
Same as useHookFormAction, but action is the return type of useOptimisticAction instead of useAction. This means action also includes optimisticState:
Prop
Type
Example
Define the action
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { actionClient } from "@/lib/safe-action";
export type Item = { id: string; name: string };
const addItemSchema = z.object({
name: z.string().min(1, "Name is required").max(50),
});
export const addItem = actionClient
.inputSchema(addItemSchema)
.action(async ({ parsedInput }) => {
const item = { ...parsedInput, id: crypto.randomUUID() };
await db.item.create({ data: item });
revalidatePath("/items");
return { newItem: item };
});Create the Server Component
import { db } from "@/lib/db";
import { ItemForm } from "./item-form";
export default async function ItemsPage() {
const items = await db.item.findMany();
return <ItemForm items={items} />;
}Create the Client Component with optimistic updates
"use client";
import { useHookFormOptimisticAction } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import type { Item } from "./actions";
import { addItem } from "./actions";
const schema = z.object({
name: z.string().min(1, "Name is required").max(50),
});
export function ItemForm({ items }: { items: Item[] }) {
const { form, action, handleSubmitWithAction, resetFormAndAction } =
useHookFormOptimisticAction(addItem, zodResolver(schema), {
actionProps: {
currentState: { items },
updateFn: (state, input) => ({
items: [...state.items, { ...input, id: crypto.randomUUID() }],
}),
onSuccess() {
form.reset();
},
},
formProps: {
defaultValues: {
name: "",
},
},
});
return (
<div>
<form onSubmit={handleSubmitWithAction}>
<input {...form.register("name")} placeholder="Item name" />
{form.formState.errors.name && (
<p>{form.formState.errors.name.message}</p>
)}
<button type="submit" disabled={action.isPending}>
{action.isPending ? "Adding..." : "Add item"}
</button>
</form>
<ul>
{action.optimisticState.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}useHookFormActionErrorMapper
A lower-level hook for advanced use cases where you want full control over useAction and useForm separately. It takes a validation errors object and returns react-hook-form-compatible FieldErrors that you can pass to useForm's errors prop.
import { useHookFormActionErrorMapper } from "@next-safe-action/adapter-react-hook-form/hooks";
const { hookFormValidationErrors } = useHookFormActionErrorMapper(validationErrors, props?);Parameters
Prop
Type
Return object
Prop
Type
Example
"use client";
import { useHookFormActionErrorMapper } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAction } from "next-safe-action/hooks";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { buyProduct } from "./actions";
const schema = z.object({
productId: z.string().min(1, "Product ID is required"),
quantity: z.coerce.number().min(1, "Must order at least 1"),
});
export function BuyProductForm() {
// Step 1: Use the action hook separately
const {
execute,
result,
status,
reset: resetAction,
isPending,
} = useAction(buyProduct);
// Step 2: Map server validation errors to react-hook-form format
const { hookFormValidationErrors } = useHookFormActionErrorMapper(
result.validationErrors
);
// Step 3: Use the form hook separately, passing mapped errors
const {
register,
handleSubmit,
reset: resetForm,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
errors: hookFormValidationErrors,
defaultValues: {
productId: "",
quantity: 1,
},
});
return (
<form onSubmit={handleSubmit((data) => execute(data))}>
<div>
<input {...register("productId")} placeholder="Product ID" />
{errors.productId && <p>{errors.productId.message}</p>}
</div>
<div>
<input type="number" {...register("quantity")} />
{errors.quantity && <p>{errors.quantity.message}</p>}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Purchasing..." : "Buy product"}
</button>
<button
type="button"
onClick={() => {
resetForm();
resetAction();
}}
>
Reset
</button>
</form>
);
}This pattern is useful when you need to customize the action hook behavior (e.g. use execute instead of executeAsync), add logic between form submission and action execution, or integrate with existing useForm setups.
mapToHookFormErrors
A non-hook utility function that maps next-safe-action validation errors to react-hook-form FieldErrors. This is the underlying function used by useHookFormActionErrorMapper (which wraps it in useMemo).
import { mapToHookFormErrors } from "@next-safe-action/adapter-react-hook-form";Parameters
Prop
Type
Return value
Returns a FieldErrors object compatible with react-hook-form, or undefined if there are no validation errors. Each field error has type: "validate" and a message string.
Example
import { mapToHookFormErrors } from "@next-safe-action/adapter-react-hook-form";
const validationErrors = {
email: { _errors: ["Invalid email", "Email already taken"] },
name: { _errors: ["Too short"] },
};
const fieldErrors = mapToHookFormErrors(validationErrors);
// → { email: { type: "validate", message: "Invalid email Email already taken" }, name: { type: "validate", message: "Too short" } }
// Use joinBy to customize how multiple errors are joined
const fieldErrors2 = mapToHookFormErrors(validationErrors, { joinBy: ", " });
// → { email: { type: "validate", message: "Invalid email, Email already taken" }, ... }Type utilities
The adapter exports two utility types for inferring the return type of the hooks from an action. These are useful when you need to type a variable or prop that holds the hook's return value.
InferUseHookFormActionHookReturn
Infer the return type of useHookFormAction from a safe action function:
import type { InferUseHookFormActionHookReturn } from "@next-safe-action/adapter-react-hook-form/hooks";
// Given a safe action:
const myAction = actionClient.inputSchema(schema).action(async ({ parsedInput }) => {
return { success: true };
});
// Infer the hook return type:
type MyHookReturn = InferUseHookFormActionHookReturn<typeof myAction>;InferUseHookFormOptimisticActionHookReturn
Infer the return type of useHookFormOptimisticAction from a safe action function and a state type:
import type { InferUseHookFormOptimisticActionHookReturn } from "@next-safe-action/adapter-react-hook-form/hooks";
type MyState = { items: Item[] };
type MyOptimisticHookReturn = InferUseHookFormOptimisticActionHookReturn<typeof myAction, MyState>;See also
- Hooks guide — the
useActionhook that the adapter builds on - Hooks API reference — full type signatures for
useActionanduseOptimisticAction - Optimistic Updates —
useOptimisticActionin depth - Form Actions — alternative form patterns without React Hook Form
- Custom Validation Errors — customizing error shapes for form display