End-to-end typesafe success, error & validation state control for Next.js form actions.
Action Creator
- β
Provides envelope objects with
"initial" | "invalid" | "success" | "failure"
response types. - β Define generic payload for each of the response type.
tRPC-like Form Action builder
- β
Define payload schema with the
.input(zodSchema)
to validate theformData
- β
Reuse business logic with the
.use(middleware)
method. - β
Reuse error handling with the
.error(handler)
.
React Context access with the <Action action={myFormAction} />
component
- β
The
useActionState()
accessible via theuseActionContext()
hook. - β
Computes progress flags like
isInvalid
,isSuccess
based on the envelope type.
Context-bound <Form />
component
- β
Reads the
action
from the<Action />
context. - β Opt-out from the default form reset after action submit.
npm i react-form-action zod-form-data
// app/subscribe/action.ts
"use server";
import { formAction } from "react-form-action";
import { z } from "zod";
export const subscribeAction = formAction
.input(z.object({ email: z.string().email() }))
.run(async ({ input }) => {
return input.email;
});
// app/subscribe/SubscribeForm.tsx
"use client";
import {
Form,
Pending,
createComponents,
useActionContext,
} from "react-form-action/client";
import { subscribeAction } from "./action";
const { FieldError, Success } = createComponents(subscribeAction);
export function SubscribeForm() {
const { isPending, isFailure, error, data } =
useActionContext(subscribeAction);
return (
<Form>
<Success>
<p>β
Email {data} was registered.</p>
</Success>
<input name="email" />
{/*π‘ The FieldError "name" prop supports autocompletion */}
<FieldError name="email" />
<button type="submit" disabled={isPending}>
{isPending ? "π Submitting..." : "Submit"}
</button>
<Pending>Please wait...</Pending>
</Form>
);
}
// app/subscribe/page.tsx
import { Action } from "react-form-action/client";
import { SubscribeForm } from "./SubscribeForm";
import { subscribeAction } from "./action";
export default function Page() {
return (
<Action action={subscribeAction} initialData="">
<SubscribeForm />
</Action>
);
}
The zod-form-data
powered action builder.
// app/actions/auth.ts
"use server";
import { formAction } from "react-form-action";
import { z } from "zod";
import { cookies } from "next/headers";
const i18nMiddleware = async () => {
const { t } = await useTranslation("auth", cookies().get("i18n")?.value);
// will be added to context
return { t };
};
const authAction = formAction
.use(i18nMiddleware)
.use(async ({ ctx: { t } }) =>
console.log("π context enhanced by previous middlewares π", t)
)
.error(async ({ error }) => {
if (error instanceof DbError) {
return error.custom.code;
} else {
// unknown error
// default Next.js error handling (error.js boundary)
throw error;
}
});
export const signIn = authAction
.input(z.object({ email: z.string().email() }))
// π extend the previous input (only without refinements and transforms)
.input(z.object({ password: z.string() }))
.run(async ({ ctx: { t }, input: { email, password } }) => {
// Type inferred: {email: string, password: string}
await db.signIn({ email, password });
return t("verificationEmail.success");
});
export const signUp = authAction
.input(
z
.object({
email: z.string().email(),
password: z.string(),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ["confirm"],
})
) // if using refinement, only one input call is permited, as schema with ZodEffects is not extendable.
.run(async ({ ctx: { t }, input: { email, password } }) => {
// π passwords match!
const tokenData = await db.signUp({ email, password });
if (!tokenData) {
return t("signUp.emailVerificationRequired");
}
return t("singUp.success");
});
Low-level action creator, which provides the success
, failure
and invalid
envelope constructors. With the createFormAction
you must handle the native FormData
by yourself.
"use server";
import { createFormAction } from "react-form-action";
import { z } from "zod";
// Define custom serializable error & success data types
type ErrorData = {
message: string;
};
type SuccessData = {
message: string;
};
type ValiationError = {
name?: string;
};
const updateUserSchema = z.object({ name: z.string() });
export const updateUser = createFormAction<
SuccessData,
ErrorData,
ValiationError
>(({ success, failure, invalid }) =>
// success and failure helper functions create wrappers for success & error data respectively
async (prevState, formData) => {
if (prevState.type === "initial") {
// use the initialData passed to <Form /> here
// prevState.data === "foobar"
}
try {
const { name } = updateUserSchema.parse({
name: formData.get("name"),
});
const user = await updateCurrentUser(name);
if (user) {
// {type: "success", data: "Your profile has been updated.", error: null, validationError: null}
return success({
message: "Your profile has been updated.",
});
} else {
// {type: "error", data: null, error: { message: "No current user." }, validationError: null}
return failure({ message: "No current user." });
}
} catch (error) {
if (error instanceof ZodError) {
// {type: "invalid", data: null, error: null, validationError: {name: "Invalid input"}}
return invalid({
name: error.issues[0]?.message ?? "Validation error",
});
}
return failure({ message: "Failed to update user." });
}
}
);
The <Action>
components enables you to access your action
's state with the useActionContext()
hook:
// π Define standalone client form component (e.g. /app/auth/signup/SignUpForm.tsx)
"use client";
import { Action, Form, useActionContext } from "react-form-action/client";
import type { PropsWithChildren } from "react";
import { signupAction } from "./action";
function Pending({ children }: PropsWithChildren) {
// read any state from the ActionContext:
const {
error,
data,
validationError,
isPending,
isFailure,
isInvalid,
isSuccess,
isInitial,
} = useActionContext();
return isPending && children;
}
// π‘ render this form on your RSC page (/app/auth/signup/page.tsx)
export function SignupForm() {
return (
<Action action={signupAction}>
<Form>
<input name="email" />
<input name="password" />
</Form>
{/* π Read the pending state outside the <Form> */}
<Pending>
{/* This renders only when the action is pending. π */}
<p>Please wait...</p>
</Pending>
</Action>
);
}
The <form>
submits the action in onSubmit
handler to prevent automatic form reset.
Pass autoReset
prop to use the action
prop instead and keep the default reset.
"use client";
import { Action, Form } from "react-form-action/client";
import { updateUser } from "./action";
export function UpdateUserForm() {
return (
<Action action={updateUser}>
<Form autoReset>{/* ... */}</Form>
</Action>
);
}
Use the createComponents(action)
helper to create components which use the ActionContext and have types bound to the action type.
"use client";
// β οΈ createComponents is usable only in "use client" components
import { Form, createComponents } from "react-form-action/client";
import { authAction } from "./actions";
export const signUpAction = authAction
.input(
z
.object({
user: z.object({
email: z.string().email(),
name: z.string(),
}),
password: z.string().min(8),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
})
)
.run(async ({ ctx, input }) => {
return null;
});
// π The FieldError is now bound do the signUpAction input schema which allows autocompletion for its "name" prop
// β οΈ Usable only with actions created with the formAction builder
const { FieldError } = createComponents(signUpAction);
export function SignUpForm() {
return (
<Action action={signUpAction} initialData={null}>
<Form>
{/* 1οΈβ£ When the "name" prop is ommited, the top-level error will be rendered e.g.:
"Passwords don't match" */}
<FieldError />
{/* 2οΈβ£ Access fields by their name: */}
<FieldError name="password" />
{/* 3οΈβ£ Access nested fields by dot access notation: */}
<FieldError name="user.email" />
</Form>
</Action>
);
}
import { Action, createComponents } from "react-form-action/client";
const { Success } = createComponents(signUpAction);
function MyForm() {
return (
<Action action={signUpAction}>
<Success>
{/* π The message will render only after the action has succeeded */}
<p>You've been signed up!</p>
</Success>
</Action>
);
}
import { createComponents } from "react-form-action/client";
const { Success } = createComponents(signUpAction);
function Label({children}: PropsWithChildren) {
return (
<Success>
{({ isSuccess, data }) => (
{/* π With a render prop, the children are always mounted, regardles of the isSuccess flag */}
<label className={isSuccess ? "green" : ""}>
{children}
</label>
)}
</Success>
);
};
Render children when the action is pending:
import { Action, Pending } from "react-form-action/client";
import { Spinner } from "./components";
function MyForm() {
return (
<Action action={action}>
{/* π Unlike the React.useFormStatus() hook, we don't need here the <form> element at all. */}
<Pending>
{/* π The spinner will UNMOUNT when the action is NOT pending */}
<Spinner />
</Pending>
</Action>
);
}
import { Pending } from "react-form-action/client";
function SubmitButton() {
return (
<Pending>
{({ isPending }) => (
{/* π With a render prop, the children are always mounted, regardles of the isPending flag */}
<button type="submit" disabled={isPending}>
{isPending ? "Submitting..." : "Submit"}
</button>
)}
</Pending>
);
};