next-safe-action Form Integration
Options
| Approach |
When to Use |
useAction + native form |
Simple forms, no complex validation UI, programmatic triggers |
useStateAction + <form action={formAction}> |
Forms with state tracking, need prevResult access, full callbacks |
useHookFormAction (RHF adapter) |
Complex forms with field-level errors, validation on change/blur |
useHookFormOptimisticAction |
RHF forms with optimistic UI updates |
Quick Start — useStateAction Form
"use client";
import { useStateAction } from "next-safe-action/hooks";
import { submitContact } from "@/app/actions";
export function ContactForm() {
const { formAction, result, isPending, hasSucceeded } = useStateAction(submitContact, {
onSuccess: () => toast.success("Message sent!"),
});
return (
<form action={formAction}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
{result.validationErrors?.email && (
<p>{result.validationErrors.email._errors?.[0]}</p>
)}
{result.serverError && <p>{result.serverError}</p>}
{hasSucceeded && <p>Message sent!</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
</form>
);
}
Note: useStateAction requires the server action to be defined with .stateAction() instead of .action(). See the hooks skill for the full decision table on when to use useAction vs useStateAction.
Quick Start — Native Form
"use client";
import { useAction } from "next-safe-action/hooks";
import { submitContact } from "@/app/actions";
export function ContactForm() {
const { execute, result, isPending } = useAction(submitContact);
return (
<form
=> {
e.preventDefault();
const fd = new FormData(e.currentTarget);
execute({
name: fd.get("name") as string,
email: fd.get("email") as string,
message: fd.get("message") as string,
});
}}
>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
{result.validationErrors && (
<p>{result.validationErrors.email?._errors?.[0]}</p>
)}
{result.serverError && <p>{result.serverError}</p>}
{result.data && <p>Message sent!</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
</form>
);
}
Quick Start — React Hook Form Adapter
"use client";
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { submitContact } from "@/app/actions";
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
export function ContactForm() {
const { form, handleSubmitWithAction, action } = useHookFormAction(
submitContact,
zodResolver(schema),
{
actionProps: {
onSuccess: () => toast.success("Message sent!"),
},
}
);
return (
<form
<input {...form.register("name")} />
{form.formState.errors.name && <p>{form.formState.errors.name.message}</p>}
<input {...form.register("email")} />
{form.formState.errors.email && <p>{form.formState.errors.email.message}</p>}
<textarea {...form.register("message")} />
{form.formState.errors.message && <p>{form.formState.errors.message.message}</p>}
{action.result.serverError && <p>{action.result.serverError}</p>}
<button type="submit" disabled={action.isPending}>
{action.isPending ? "Sending..." : "Send"}
</button>
</form>
);
}
Supporting Docs
Entry Points
| Package |
Entry Point |
Exports |
@next-safe-action/adapter-react-hook-form |
Default |
mapToHookFormErrors, types |
@next-safe-action/adapter-react-hook-form/hooks |
Hooks |
useHookFormAction, useHookFormOptimisticAction, useHookFormActionErrorMapper |
1---2name: safe-action-forms3description: Use when integrating next-safe-action with forms -- react-hook-form adapter (useHookFormAction, useHookFormOptimisticAction, mapToHookFormErrors), native HTML forms, bind arguments, or file uploads4---56# next-safe-action Form Integration78## Options910| Approach | When to Use |11|---|---|12| `useAction` + native form | Simple forms, no complex validation UI, programmatic triggers |13| `useStateAction` + `<form action={formAction}>` | Forms with state tracking, need `prevResult` access, full callbacks |14| `useHookFormAction` (RHF adapter) | Complex forms with field-level errors, validation on change/blur |15| `useHookFormOptimisticAction` | RHF forms with optimistic UI updates |1617## Quick Start — useStateAction Form1819```tsx20"use client";2122import { useStateAction } from "next-safe-action/hooks";23import { submitContact } from "@/app/actions";2425export function ContactForm() {26 const { formAction, result, isPending, hasSucceeded } = useStateAction(submitContact, {27 onSuccess: () => toast.success("Message sent!"),28 });2930 return (31 <form action={formAction}>32 <input name="name" required />33 <input name="email" type="email" required />34 <textarea name="message" required />3536 {result.validationErrors?.email && (37 <p>{result.validationErrors.email._errors?.[0]}</p>38 )}39 {result.serverError && <p>{result.serverError}</p>}40 {hasSucceeded && <p>Message sent!</p>}4142 <button type="submit" disabled={isPending}>43 {isPending ? "Sending..." : "Send"}44 </button>45 </form>46 );47}48```4950Note: `useStateAction` requires the server action to be defined with `.stateAction()` instead of `.action()`. See the [hooks skill](../safe-action-hooks/use-state-action.md) for the full decision table on when to use `useAction` vs `useStateAction`.5152## Quick Start — Native Form5354```tsx55"use client";5657import { useAction } from "next-safe-action/hooks";58import { submitContact } from "@/app/actions";5960export function ContactForm() {61 const { execute, result, isPending } = useAction(submitContact);6263 return (64 <form65 onSubmit={(e) => {66 e.preventDefault();67 const fd = new FormData(e.currentTarget);68 execute({69 name: fd.get("name") as string,70 email: fd.get("email") as string,71 message: fd.get("message") as string,72 });73 }}74 >75 <input name="name" required />76 <input name="email" type="email" required />77 <textarea name="message" required />7879 {result.validationErrors && (80 <p>{result.validationErrors.email?._errors?.[0]}</p>81 )}82 {result.serverError && <p>{result.serverError}</p>}83 {result.data && <p>Message sent!</p>}8485 <button type="submit" disabled={isPending}>86 {isPending ? "Sending..." : "Send"}87 </button>88 </form>89 );90}91```9293## Quick Start — React Hook Form Adapter9495```tsx96"use client";9798import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks";99import { zodResolver } from "@hookform/resolvers/zod";100import { z } from "zod";101import { submitContact } from "@/app/actions";102103const schema = z.object({104 name: z.string().min(1, "Name is required"),105 email: z.string().email("Invalid email"),106 message: z.string().min(10, "Message must be at least 10 characters"),107});108109export function ContactForm() {110 const { form, handleSubmitWithAction, action } = useHookFormAction(111 submitContact,112 zodResolver(schema),113 {114 actionProps: {115 onSuccess: () => toast.success("Message sent!"),116 },117 }118 );119120 return (121 <form onSubmit={handleSubmitWithAction}>122 <input {...form.register("name")} />123 {form.formState.errors.name && <p>{form.formState.errors.name.message}</p>}124125 <input {...form.register("email")} />126 {form.formState.errors.email && <p>{form.formState.errors.email.message}</p>}127128 <textarea {...form.register("message")} />129 {form.formState.errors.message && <p>{form.formState.errors.message.message}</p>}130131 {action.result.serverError && <p>{action.result.serverError}</p>}132133 <button type="submit" disabled={action.isPending}>134 {action.isPending ? "Sending..." : "Send"}135 </button>136 </form>137 );138}139```140141## Supporting Docs142143- [Native form submission patterns](./form-actions.md)144- [React Hook Form adapter in depth](./react-hook-form.md)145- [File uploads](./file-uploads.md)146147## Entry Points148149| Package | Entry Point | Exports |150|---|---|---|151| `@next-safe-action/adapter-react-hook-form` | Default | `mapToHookFormErrors`, types |152| `@next-safe-action/adapter-react-hook-form/hooks` | Hooks | `useHookFormAction`, `useHookFormOptimisticAction`, `useHookFormActionErrorMapper` |