https://github.com/miroslavpetrik/react-form-action
tRPC like builder for the Next.js & React form actions.
https://github.com/miroslavpetrik/react-form-action
next-form-action nextjs react react-form-action server-action typescript
Last synced: 26 days ago
JSON representation
tRPC like builder for the Next.js & React form actions.
- Host: GitHub
- URL: https://github.com/miroslavpetrik/react-form-action
- Owner: MiroslavPetrik
- License: mit
- Created: 2023-11-12T18:54:46.000Z (over 1 year ago)
- Default Branch: main
- Last Pushed: 2025-02-03T17:36:17.000Z (4 months ago)
- Last Synced: 2025-05-05T21:11:27.849Z (28 days ago)
- Topics: next-form-action, nextjs, react, react-form-action, server-action, typescript
- Language: TypeScript
- Homepage:
- Size: 259 KB
- Stars: 24
- Watchers: 1
- Forks: 2
- Open Issues: 2
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# react-form-action
End-to-end typesafe success, error & validation state control for Next.js form actions.
## Features
**Action Creator**
- ✅ Provides envelope objects with `"initial" | "invalid" | "success" | "failure"` response types.
- ✅ Define generic payload for each of the response type.
- ✅ Bind action arguments.**tRPC-like Form Action builder**
- ✅ Define payload schema with the `.input(zodSchema)` to validate the `formData`
- ✅ Reuse business logic with the `.use(middleware)` method.
- ✅ Define bindable arguments with the `.args([])` method.
- ✅ Reuse error handling with the `.error(handler)`.**React Context access with the `` component**
- ✅ The `useActionState()` accessible via the `useActionContext()` hook.
- ✅ Computes progress flags like `isInvalid`, `isSuccess` based on the envelope type.**Context-bound `` component**
- ✅ Reads the `action` from the `` context.
- ✅ Opt-out from the default form reset after action submit.## Install
```
npm i react-form-action zod-form-data
```## Getting Started
#### 1️⃣ Create a Server Action
```tsx
// 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;
});
```#### 2️⃣ Create a Client Form Component
```tsx
// app/subscribe/form.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 (
✅ Email {data} was registered.
{/*💡 The FieldError "name" prop supports autocompletion */}
{({ name, error }) => (
)}
{isPending ? "🌀 Submitting..." : "Submit"}
Please wait...
);
}
```#### 3️⃣ Provide the `` context on a Page
```tsx
// app/subscribe/page.tsximport { Action } from "react-form-action/client";
import { subscribeAction } from "./action";
import { SubscribeForm } from "./form";export default function Page() {
return (
);
}
```## Usage
### `formAction` builder
The [`zod-form-data`](https://www.npmjs.com/package/zod-form-data) powered action builder.
```ts
// 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");
});
```#### Args binding
The `formAction` builder supports action arguments binding:
```ts
// app/update-user/[userId]/action.tsx
import { formAction } from "react-form-action";export const updateUser = formAction
.args([z.string().uuid()])
.run(async ({ args: [userId] }) => {
return userId;
// ^? string
});
``````tsx
// app/update-user/[userId]/page.tsx
import { Action } from "react-form-action/client";import { updateUser } from "./action";
import { UpdateUserForm } from "./form";export default function Page({
params,
}: {
params: Promise<{ userId: string }>;
}) {
const { userId } = await params;const action = updateUser.bind(null, userId);
return (
);
}
```### Action Creator
Low-level action creator, which provides the `success`, `failure` and `invalid` envelope constructors. With the `createFormAction` you must handle the native `FormData` by yourself.
```ts
"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 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 creator supports arguments binding:
```ts
export const updateUser = createFormAction(
(
{ success, failure, invalid },
userId: string /* Here you can specify multiple arguments */
) =>
async (prevState, formData) => {
try {
const { name } = updateUserSchema.parse({
name: formData.get("name"),
});const user = await db.users.findById(userId);
if (!user) {
return failure({ message: "No such user." });
}const updated = await user.update({ name });
if (updated) {
return success({
message: "User has been updated.",
});
} else {
return failure({ message: "Failed to update." });
}
} catch (error) {
// handle error
}
}
);// call bind as usuall, the "123" becomes the "userId"
updateUser.bind(null, "123");
```### Action Context
The `` components enables you to access your `action`'s state with the `useActionContext()` hook:
```tsx
// 👉 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 (
{/* 🎆 Read the pending state outside the */}
{/* This renders only when the action is pending. 😎 */}
Please wait...
);
}
```### `` Component
The `` submits the action in `onSubmit` handler to [prevent automatic form reset](https://github.com/facebook/react/issues/29034).
Pass `autoReset` prop to use the `action` prop instead and keep the default reset.```tsx
"use client";import { Action, Form } from "react-form-action/client";
import { updateUser } from "./action";
export function UpdateUserForm() {
return (
{/* ... */}
);
}
```### Context Bound Components `createComponents()`
Use the `createComponents(action)` helper to create components which use the ActionContext and have types bound to the action type.
#### `` Component
```tsx
"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 (
{/* 1️⃣ When the "name" prop is an empty string, the top-level error will be rendered e.g.:
"Passwords don't match" */}
{/* 2️⃣ Access fields by their name: */}
{/* 3️⃣ Access nested fields by dot access notation: */}
);
}
```#### ``
#### When children are JSX
```tsx
import { Action, createComponents } from "react-form-action/client";const { Success } = createComponents(signUpAction);
function MyForm() {
return (
{/* 👉 The message will render only after the action has succeeded */}
You've been signed up!
);
}
```#### When children is a render prop
```tsx
import { createComponents } from "react-form-action/client";const { Success } = createComponents(signUpAction);
function Label({children}: PropsWithChildren) {
return (
{({ isSuccess, data }) => (
{/* 👉 With a render prop, the children are always mounted, regardles of the isSuccess flag */}
{children}
)}
);
};
```### ``
Render children when the action is pending:
#### When children are JSX
```tsx
import { Action, Pending } from "react-form-action/client";import { Spinner } from "./components";
function MyForm() {
return (
{/* 👉 Unlike the React.useFormStatus() hook, we don't need here the element at all. */}
{/* 👉 The spinner will UNMOUNT when the action is NOT pending */}
);
}
```#### When children is a render prop
```tsx
import { Pending } from "react-form-action/client";function SubmitButton() {
return (
{({ isPending }) => (
{/* 👉 With a render prop, the children are always mounted, regardles of the isPending flag */}
{isPending ? "Submitting..." : "Submit"}
)}
);
};
```