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>
	);
}

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.

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:

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

src/types/react.d.ts
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:

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

What's next?

On this page