next-safe-action
Guides

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
src/app/actions.ts
"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

src/app/login.tsx
"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():

src/app/actions.ts
"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

src/app/login.tsx
"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:

src/app/actions.ts
"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

src/app/login.tsx
"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

FeatureuseActionuseStateActionuseActionState (React)
Works without JSNoNoYes
Previous result accessNoYes (via stateAction)Yes (via stateAction)
Form action supportNo (onSubmit only)Yes (formAction)Yes (dispatch)
Loading statesisExecuting, isPendingisExecuting, isPendingisPending
Lifecycle callbacksFullFullNone
Navigation trackingonNavigation, hasNavigatedonNavigation, hasNavigatedError boundary only
throwOnNavigationYesYesN/A (always throws)
reset()YesYesNo
executeAsyncYesYesNo
Action method.action().stateAction().stateAction()
Best forInteractive UI, programmatic triggersForms with callbacks and stateForms that must work without JS
  • Use useAction when 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 useStateAction when you need previous result access (prevResult in server code), you're building forms with rich callbacks and status tracking, or you want the <form action={formAction}> pattern with full DX.
  • Use useActionState directly when you need no-JS progressive enhancement, or you want the simplest possible form setup and don't need lifecycle callbacks.

What's next?

On this page