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>
);
}Use useStateAction when you want <form action={formAction}> integration with full lifecycle callbacks, status tracking, and navigation error handling:
Define a state action
Use .stateAction() instead of .action():
"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)
.stateAction(async ({ parsedInput: { email, password } }, { prevResult }) => {
const user = await authenticate(email, password);
return { userId: user.id, previousAttempt: prevResult.data?.userId };
});Create the form with useStateAction
"use client";
import { useStateAction } from "next-safe-action/hooks";
import { loginAction } from "./actions";
export default function LoginForm() {
const { formAction, result, isPending, hasSucceeded, hasErrored, reset } = useStateAction(loginAction, {
onSuccess: ({ data }) => {
console.log("Logged in as user", data.userId);
},
onError: ({ error }) => {
console.error("Login failed:", error.serverError);
},
});
return (
<form action={formAction}>
<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={isPending}>
{isPending ? "Logging in..." : "Log in"}
</button>
{hasSucceeded && <p>Logged in as user {result.data?.userId}</p>}
{hasErrored && result.serverError && <p>Error: {result.serverError}</p>}
{!isPending && (hasSucceeded || hasErrored) && (
<button type="button" onClick={reset}>Try again</button>
)}
</form>
);
}How it works: useStateAction wraps the action function before passing it to React's useActionState. This wrapper intercepts navigation errors (redirect, notFound, etc.) before React sees them, enabling onNavigation callbacks and hasNavigated status tracking. Because the wrapper is a client-side function, no-JS progressive enhancement is not possible. For forms that must work without JavaScript, use the "Progressive Enhancement" tab.
Use React's useActionState directly for progressive enhancement, where the form works even without JavaScript. This approach has no lifecycle callbacks or navigation tracking, but forms submit natively before React hydrates:
Define a state action
Use .stateAction() instead of .action() for stateful actions:
"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)
.stateAction(async ({ parsedInput: { email, password } }) => {
const user = await authenticate(email, password);
return { userId: user.id };
});Create the form with useActionState
"use client";
import { useActionState } from "react";
import { loginAction } from "./actions";
export default function LoginForm() {
const [result, dispatch, isPending] = useActionState(loginAction, {});
return (
<form action={dispatch}>
<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={isPending}>
{isPending ? "Logging in..." : "Log in"}
</button>
{result.data && <p>Logged in as user {result.data.userId}</p>}
{result.serverError && <p>Error: {result.serverError}</p>}
</form>
);
}Progressive enhancement: The action={dispatch} pattern means the form submits natively, even before React hydrates on the client. The server processes the action and returns updated HTML. Once JavaScript loads, the form becomes interactive with isPending states and instant validation.
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.