Form actions
next-safe-action works with HTML forms using FormData as input. This enables progressive enhancement, meaning forms that work even without JavaScript.
Stateless vs stateful
There are two approaches to form actions:
Use useAction when you want full control over execution and don't need the form to work without JavaScript:
Define the action with FormData input
When a form submits, the browser sends FormData. Use a library like zod-form-data to validate it:
npm install zod-form-data"use server";
import { z } from "zod";
import { zfd } from "zod-form-data";
import { actionClient } from "@/lib/safe-action";
const schema = zfd.formData({
email: zfd.text(z.string().email()),
password: zfd.text(z.string().min(8)),
});
export const loginAction = actionClient
.inputSchema(schema)
.action(async ({ parsedInput: { email, password } }) => {
const user = await authenticate(email, password);
return { userId: user.id };
});Create the form component
"use client";
import { useAction } from "next-safe-action/hooks";
import { useRef } from "react";
import { loginAction } from "./actions";
export default function LoginForm() {
const formRef = useRef<HTMLFormElement>(null);
const { execute, result, isExecuting } = useAction(loginAction, {
onSuccess: () => {
formRef.current?.reset();
},
});
return (
<form ref={formRef} onSubmit={(e) => { e.preventDefault(); execute(new FormData(e.currentTarget)); }}>
<input name="email" type="email" placeholder="Email" />
{result.validationErrors?.email && <p>{result.validationErrors.email._errors[0]}</p>}
<input name="password" type="password" placeholder="Password" />
{result.validationErrors?.password && <p>{result.validationErrors.password._errors[0]}</p>}
<button type="submit" disabled={isExecuting}>
{isExecuting ? "Logging in..." : "Log in"}
</button>
</form>
);
}When to use each approach
| Feature | useAction | useStateAction | useActionState (React) |
|---|---|---|---|
| Works without JS | No | No | Yes |
| Previous result access | No | Yes (via stateAction) | Yes (via stateAction) |
| Form action support | No (onSubmit only) | Yes (formAction) | Yes (dispatch) |
| Loading states | isExecuting, isPending | isExecuting, isPending | isPending |
| Lifecycle callbacks | Full | Full | None |
| Navigation tracking | onNavigation, hasNavigated | onNavigation, hasNavigated | Error boundary only |
throwOnNavigation | Yes | Yes | N/A (always throws) |
reset() | Yes | Yes | No |
executeAsync | Yes | Yes | No |
| Action method | .action() | .stateAction() | .stateAction() |
| Best for | Interactive UI, programmatic triggers | Forms with callbacks and state | Forms that must work without JS |
- Use
useActionwhen you don't need previous result access, your triggers are programmatic (buttons, events), or you're building interactive UI that doesn't use<form action={...}>. - Use
useStateActionwhen you need previous result access (prevResultin server code), you're building forms with rich callbacks and status tracking, or you want the<form action={formAction}>pattern with full DX. - Use
useActionStatedirectly when you need no-JS progressive enhancement, or you want the simplest possible form setup and don't need lifecycle callbacks.
Passing an action directly to the action prop
If you try to pass a plain action (created with .action()) straight into <form action={...}>, TypeScript rejects it:
<form action={loginAction}>
// ^ Type 'SafeActionFn<...>' is not assignable to type 'string | ((formData: FormData) => void | Promise<void>)'.This happens because React validates the action prop against an internal, experimental interface named DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS. Out of the box that interface doesn't know about a SafeActionFn, so the action's Promise<SafeActionResult<...>> return type isn't recognized as a valid form action.
If you want to pass an action to the action prop without going through a hook (useStateAction, useActionState) or casting at the call site, you can teach React's types about it by augmenting that interface in your own application code:
import type { SafeActionResult } from "next-safe-action";
declare module "react" {
interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS {
nextSafeActionFn: (...args: any[]) => Promise<SafeActionResult<any, any, any, any>>;
}
}With this declaration in scope, the action passes type checking when used directly:
<form action={loginAction}>{/* ... */}</form>This opts you into an unstable React interface. As the name shouts, DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_FORM_ACTIONS is an internal, experimental React type with no stability guarantee. Its shape (or name) can change between React versions, which would break this augmentation. next-safe-action does not apply this augmentation for you, precisely so you opt in knowingly and keep that risk in your own codebase. If you'd rather not depend on an experimental interface, use useStateAction or React's useActionState (see the tabs above), which keep the action behind a stable hook API.