Handling Optimistic Updates and Server Validation Errors at Once with `useOptimisticAction` + `react-hook-form` (next-safe-action)
That brief moment of waiting for a server response feels longer to users than you might expect. The blank pause after pressing a button with no feedback. At first I thought, "What's the problem? It takes less than 200ms anyway," but after adding optimistic updates, the perceived quality improved noticeably.
Honestly, though, what took me longer to figure out wasn't the optimistic update itself. It was the question of how to "reattach" server validation errors back to form fields. Client-side Zod validation is handled by react-hook-form, but the server's validationErrors had to be manually extracted and setError called for each one. That repetitive work was quite tedious.
This article covers how to use next-safe-action's useOptimisticAction hook to update the UI before the server responds, and how to use the @next-safe-action/adapter-react-hook-form official adapter to automatically bind server validation errors to form fields. The key is integrating three concerns — optimistic updates, server actions, and form validation — into a type-safe flow that starts from a single Zod schema. The conceptual explanation is followed immediately by real-world code, so let's walk through it together.
Core Concepts
What is next-safe-action?
next-safe-action is a third-party library that helps with type safety and middleware handling for Next.js Server Actions. It's not an official Next.js package, but it has become practically indispensable for using server actions in the App Router ecosystem. Declare your inputs and outputs with a Zod schema and it handles everything from type inference to formatting validation errors.
// lib/safe-action.ts — the action client shared across the entire project
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient();This single-line config file becomes the starting point for every server action thereafter.
Optimistic Updates and useOptimisticAction
An optimistic update is a pattern where the UI is updated before waiting for a server response. You "optimistically" assume the server operation will succeed, change the screen first, and roll back to the original state if it fails.
| Approach | User Experience | Implementation Complexity |
|---|---|---|
| Standard (update after server response) | Wait after pressing button | Low |
Using useOptimistic directly |
Instant update + manual rollback | High |
Using useOptimisticAction |
Instant update + automatic rollback | Low |
useOptimisticAction is built on top of React 19's useOptimistic and handles the Safe Action integration, rollback logic, and type inference all at once. Note that from v7 onward, the second argument changed to an options object.
// v6 (old)
useOptimisticAction(action, currentState, updateFn, callbacks);
// v7 (current) — second argument unified into a single options object
useOptimisticAction(action, {
currentState,
updateFn,
onSuccess,
onError,
});Pure function constraint for
updateFn:updateFnruns inside the React render cycle, so it must be a pure function. Putting async logic or external state mutations inside it leads to unpredictable bugs. When the server action succeeds,revalidatePathorrevalidateTagrefreshes the server component, naturally replacing the optimistic state with real data.
Connecting Server Validation Errors to react-hook-form
The useHookFormActionErrorMapper hook from the @next-safe-action/adapter-react-hook-form adapter solves this problem. It converts the server's validationErrors format into the FieldErrors format that react-hook-form understands, then injects it via the errors option in useForm to automatically bind errors to each field.
const { hookFormValidationErrors } = useHookFormActionErrorMapper(
action.result.validationErrors,
{ joinBy: "\n" }
);
const form = useForm({
resolver: zodResolver(schema),
errors: hookFormValidationErrors, // server errors automatically bound to form fields
});
joinByoption: The separator used to join multiple validation error messages for a single field. You can choose"\n"," | ",", ", etc. depending on the situation. If you need a more complex error UI, you can opt for parsingvalidationErrorsdirectly.
Practical Application
Example 1: Instantly Adding a Todo with useOptimisticAction
This is the most typical use case. When the "Add" button is pressed, an item appears in the list before the server responds.
Define the server action first.
// app/todos/actions.ts
"use server";
import { actionClient } from "@/lib/safe-action";
import { z } from "zod";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
const addTodoSchema = z.object({
newTodo: z.string().min(1, "Please enter a todo"),
});
export const addTodoAction = actionClient
.schema(addTodoSchema)
.action(async ({ parsedInput }) => {
await db.todo.create({ data: { text: parsedInput.newTodo } });
revalidatePath("/todos"); // replace with real data after server success
return { success: true };
});Use useOptimisticAction in the client component.
// app/todos/AddTodoForm.tsx
"use client";
import { useState } from "react";
import { useOptimisticAction } from "next-safe-action/hooks";
import { addTodoAction } from "./actions";
type Props = { todos: string[] };
export function AddTodoForm({ todos }: Props) {
const [input, setInput] = useState("");
const { execute, optimisticState, isExecuting } = useOptimisticAction(
addTodoAction,
{
currentState: { todos },
// input type is automatically inferred from addTodoSchema — { newTodo: string }
updateFn: (state, input) => ({
todos: [...state.todos, input.newTodo],
}),
onSuccess: () => setInput(""),
onError: ({ error }) => {
console.error("Server error:", error);
// the optimistic state from updateFn is automatically rolled back to currentState
},
}
);
return (
<div>
<ul>
{optimisticState.todos.map((todo, i) => (
// In real code, it's recommended to use the DB's unique id as the key
<li key={`todo-${i}`}>{todo}</li>
))}
</ul>
<div className="flex gap-2 mt-4">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Enter a new todo"
className="border rounded px-3 py-2 flex-1"
/>
<button
onClick={() => {
if (!input.trim()) return;
execute({ newTodo: input });
}}
disabled={isExecuting}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isExecuting ? "Saving..." : "Add"}
</button>
</div>
</div>
);
}Pass the initial data down from the server component.
// app/todos/page.tsx
import { AddTodoForm } from "./AddTodoForm";
import { db } from "@/lib/db";
export default async function TodoPage() {
const todos = await db.todo.findMany({ select: { text: true } });
// App Router's basic pattern: fetch data on the server, then pass it as initial state to the client component
return <AddTodoForm todos={todos.map((t) => t.text)} />;
}| Code Point | Description |
|---|---|
currentState: { todos } |
Initial state received from the server. Automatically rolled back to this value if the action fails |
input type in updateFn |
Automatically inferred from addTodoSchema — no separate type declaration needed |
optimisticState.todos |
The optimistic list used for rendering immediately after execute() |
revalidatePath |
After server success, the server component refreshes and replaces the data with actual DB data |
Example 2: Automatically Binding Server validationErrors to Form Fields
This handles the case where client-side Zod validation passes but the server returns additional validation errors. A natural question arises at this point: "Do I have to call setError one by one when receiving server errors?" — With the adapter, you don't need to.
// app/login/LoginForm.tsx
"use client";
import { useHookFormActionErrorMapper } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useAction } from "next-safe-action/hooks";
import { z } from "zod";
import { loginAction } from "./actions";
const loginSchema = z.object({
email: z.string().email("Invalid email format"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type LoginInput = z.infer<typeof loginSchema>;
export function LoginForm() {
const action = useAction(loginAction);
const { hookFormValidationErrors } = useHookFormActionErrorMapper(
action.result.validationErrors,
{ joinBy: "\n" }
);
const form = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
errors: hookFormValidationErrors, // server errors → automatically bound to form fields
});
// executeAsync returns a Promise, so it
// is compatible with form.handleSubmit's argument signature without type errors
return (
<form onSubmit={form.handleSubmit(action.executeAsync)}>
<div>
<input
{...form.register("email")}
placeholder="Email"
type="email"
className="border rounded px-3 py-2 w-full"
/>
{form.formState.errors.email && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="mt-3">
<input
{...form.register("password")}
placeholder="Password"
type="password"
className="border rounded px-3 py-2 w-full"
/>
{form.formState.errors.password && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.password.message}
</p>
)}
</div>
<button
type="submit"
disabled={action.isExecuting}
className="mt-4 bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{action.isExecuting ? "Logging in..." : "Log in"}
</button>
</form>
);
}Client-side Zod validation (instant feedback) and server validation (final security check) are seamlessly unified into the same form state. Reading form.formState.errors once gives you errors from both sources.
Dual-side validation pattern: On the client, use Zod +
react-hook-formfor instant feedback; on the server, usenext-safe-action's schema validation for a final security check. Client-side validation can be bypassed with browser developer tools, so server-side validation must never be omitted.
Example 3: Combining All Three with useHookFormOptimisticAction
When I first saw this API name, I honestly flinched at how long it was — but after actually using it, optimistic updates + form management + server validation error binding are all handled at once. What was configured separately in the previous two examples is declared together using three options: actionProps, formProps, and errorMapProps.
// app/todos/AddTodoForm.tsx
"use client";
import { useHookFormOptimisticAction } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { addTodoAction } from "./actions";
const addTodoSchema = z.object({
newTodo: z.string().min(1, "Please enter a todo"),
});
type Props = { todos: string[] };
export function AddTodoForm({ todos }: Props) {
const { form, action, handleSubmitWithAction } = useHookFormOptimisticAction(
addTodoAction,
zodResolver(addTodoSchema),
{
actionProps: {
currentState: { todos },
updateFn: (state, input) => ({
todos: [...state.todos, input.newTodo],
}),
},
formProps: {
defaultValues: { newTodo: "" },
},
errorMapProps: { joinBy: " | " },
}
);
return (
<div>
{/* render the optimistic list with action.optimisticState — reflected immediately after execute */}
<ul className="mb-4 space-y-1">
{action.optimisticState.todos.map((todo, i) => (
<li key={`todo-${i}`} className="text-sm">
{todo}
</li>
))}
</ul>
<form onSubmit={handleSubmitWithAction} className="flex gap-2">
<input
{...form.register("newTodo")}
placeholder="Enter a new todo"
className="border rounded px-3 py-2 flex-1"
/>
{form.formState.errors.newTodo && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.newTodo.message}
</p>
)}
<button
type="submit"
disabled={action.isExecuting}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{action.isExecuting ? "Saving..." : "Add"}
</button>
</form>
</div>
);
}| Return Value | Role |
|---|---|
form |
react-hook-form's UseFormReturn object — includes register, formState, and everything else |
action |
Return value of useOptimisticAction — includes isExecuting, optimisticState, etc. |
handleSubmitWithAction |
A submit handler that connects form.handleSubmit with action.execute |
Using action.optimisticState for the actual list rendering is the core reason to choose this hook. It's the sole piece of state actually used in the UI, and since it reflects immediately after calling execute(), there's no need for separate local state management.
Pros and Cons
Pros
| Item | Details |
|---|---|
| Instant UX | The UI updates immediately without waiting for a server response, significantly improving perceived speed |
| Automatic rollback | On server action failure, the original state is automatically restored without any extra logic |
| End-to-end type safety | TypeScript types are consistently inferred from the Zod schema through to the server action, hook, and form |
| Unified dual-side validation | Client (instant feedback) and server (security validation) can be managed with the same schema |
| Automatic error binding | Server validationErrors are automatically connected to form fields without setError |
| Reduced boilerplate | useHookFormOptimisticAction consolidates three concerns into one |
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
updateFn must be pure |
Side effects and async logic are not allowed since it runs during the render cycle | Write as a pure function containing only state calculation logic |
| Optimistic/actual state mismatch | A brief flicker may occur if the server returns a different value | Design so that currentState and the updateFn result match as closely as possible |
| Server Component prop-passing structure | currentState must be passed as props from the server component to the client |
Maintain the pattern of fetching in server components and injecting into client components |
| Limitations with complex optimistic logic | May not be suitable for relational data or complex state transitions | Recommended for simple state changes like adding/removing items from a list |
| Next.js dependency | Tightly coupled to Next.js App Router and Server Actions | Consider alternative approaches in non-Next.js environments |
| Error message joining | Multiple validation errors are joined by joinBy, requiring separate handling for complex error UIs |
Parse validationErrors directly to build a custom UI |
Most Common Mistakes in Practice
-
Putting async logic or external state mutations inside
updateFn— Since it runs during rendering, this leads to unpredictable bugs.updateFnmust be a pure calculation function of the form(state, input) => newState. -
Using v6 API signatures in v7 — In v7, the second argument changed from flat parameters to an options object. When migrating existing code, it's recommended to check the official v7 release notes.
-
Fetching
currentStatedirectly on the client — If thecurrentStatethat serves as the rollback baseline foruseOptimisticActionis not synchronized with the actual server data, the state after rollback will be out of sync.currentStatemust always be based on data passed down as props from a server component.
Closing Thoughts
There are 3 steps you can start with right now.
-
Start by adding the dependencies. Run
pnpm add next-safe-action react-hook-form @hookform/resolvers zod @next-safe-action/adapter-react-hook-form, then callcreateSafeActionClient()inlib/safe-action.tsand export theactionClient— everything that follows becomes much easier. -
It's recommended to pick one of your existing Server Actions and migrate it to
useOptimisticAction. Starting with simple state changes like todo lists, like buttons, or comment additions will help you get the hang of the concept quickly. -
Try attaching
useHookFormActionErrorMapperto a form that has server validation errors. You'll experience the manually written server error binding logic disappearing, and client and server validation errors being managed under the same form state.
The combination of useOptimisticAction, react-hook-form, and next-safe-action is a practical choice for simultaneously elevating the user experience and type safety of server action-based forms in a Next.js App Router environment.
References
Official Documentation
- next-safe-action Official Docs
- useOptimisticAction() API | next-safe-action
- Optimistic Updates Guide | next-safe-action
- React Hook Form Integration | next-safe-action
- useOptimistic – React Official Docs
Packages
- @next-safe-action/adapter-react-hook-form | NPM
- adapter-react-hook-form | GitHub
- next-safe-action | GitHub
Reference Articles