diff --git a/README.md b/README.md index 23ef58c..41645ce 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,29 @@ # react-form-action -End-to-end typesafe success, error & validation state control for Next.js 14 form actions. +End-to-end typesafe success, error & validation state control for Next.js form actions. ## Features **Action Creator** -- ✅ Provides `"invalid" | "success" | "failure"` response objects. +- ✅ Provides envelope objects with `"initial" | "invalid" | "success" | "failure"` response types. - ✅ Define generic payload for each of the response type. -**Form Action builder** +**tRPC-like Form Action builder** -- ✅ tRPC-like builder API for `.input(zodSchema)` & context `.use(middleware)` -- ✅ Parses `formData` with [`zod-form-data`](https://www.npmjs.com/package/zod-form-data) +- ✅ Define payload schema with the `.input(zodSchema)` to validate the `formData` +- ✅ Reuse business logic with the `.use(middleware)` method. +- ✅ Reuse error handling with the `.error(handler)`. -**Stateful `
`** +**React Context access with the `` component** -- ✅ `` component reads the action's response. -- ✅ Computes progress meta-state like `isInvalid`, `isSuccess` and more. +- ✅ 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 @@ -29,10 +35,95 @@ npm i react-form-action zod-form-data NPM Version +## Getting Started + +#### 1️⃣ Create a Server Action + +```tsx +// app/subscibe/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/subscibe/SubscribeForm.tsx +"use client"; + +import { + Action, + Form, + Pending, + useActionContext, +} from "react-form-action/client"; + +import { subscribeAction } from "./action"; + +const { FieldError } = createComponents(subscribeAction); + +export function SubscribeForm() { + return ( + + + + + {/*💡 The name prop supports autocompletion */} + + + + Please wait... + + ); +} + +function SuccessMessage() { + // Pass action to cast the type of the "data" value + const { isSuccess, data } = useActionContext(subscribeAction); + + return isSuccess &&

Email {data} was registered.

; +} + +function SubmitButton() { + // no need for action when reading a generic value + const { isPending } = useActionContext(); + + return ( + + ); +} +``` + +#### 3️⃣ Render the form on a Page + +```tsx +// app/subscibe/page.tsx + +import { SubscribeForm } from "./SubscribeForm"; + +function SuccessMessage() {} + +export 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"; @@ -50,7 +141,7 @@ const i18nMiddleware = async () => { const authAction = formAction .use(i18nMiddleware) .use(async ({ ctx: { t } }) => - console.log("🎉 context enhanced by previous middlewares 🎉", t), + console.log("🎉 context enhanced by previous middlewares 🎉", t) ) .error(({ error }) => { if (error instanceof DbError) { @@ -85,7 +176,7 @@ export const signUp = authAction .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! @@ -100,7 +191,9 @@ export const signUp = authAction }); ``` -### Server Action (usable in Next.js) +### 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"; @@ -161,59 +254,91 @@ export const updateUser = createFormAction< return failure({ message: "Failed to update user." }); } - }, + } ); ``` +### 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"; -// Form connects the formAction to the formState & provides the meta state props via render prop -import { Form } from "react-form-action/client"; -import { updateUser } from "@/actions"; +import { Action, Form } from "react-form-action/client"; + +import { updateUser } from "./action"; export function UpdateUserForm() { return ( - - {({ - error, - data, - validationError, - isPending, - isFailure, - isInvalid, - isSuccess, - isInitial, - }) => ( - <> - {/* safely access the data or error by checking the mutually exclusive boolean flags: */} - {isSuccess &&

{data}

} - {isFailure &&

{error.message}

} - - {isInvalid && ( - {validationError.name} - )} - - - - )} -
+ +
{/* ... */}
+
); } ``` -### `` Component +### Context Bound Components `createComponents()` + +Use the `createComponents(action)` helper to create components which use the ActionContext and have types bound to the action type. -Actions created with `formAction` builder will have the `validationError` of [`ZodFormattedError`](https://zod.dev/ERROR_HANDLING?id=formatting-errors) type. -To easily access the nested error, use the helper `` component: +### `` Component ```tsx "use client"; -import { Form, ZodFieldError } from "react-form-action/client"; + +// ⚠️ createComponents is usable only in "use client" components +import { Form, createComponents } from "react-form-action/client"; + +import { authAction } from "./actions"; export const signUp = authAction .input( @@ -231,51 +356,47 @@ export const signUp = authAction }) ) .run(async ({ ctx, input }) => { - // implementation + return null; }); +// 🌟 The FieldError is now bound do the signUp input schema which allows autocompletion for its "name" prop +// ⚠️ Usable only with actions created with the formAction builder +const { FieldError } = createComponents(signUp); + export function SignUpForm() { return ( -
- {({ isInvalid, validationError }) => - {/* Render ZodFieldError behind the isInvalid flag to narrow type (omits the possibility of null) */} - isInvalid && ( - <> - {/* - When the "name" prop is ommited, the top-level error will be rendered e.g.: - "Passwords don't match" - */} - - {/* Access fields by their name: */} - - {/* Access nested fields by dot access notation: */} - - - ) - } - + +
+ {/* 1️⃣ When the "name" prop is ommited, 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: */} + + +
); } ``` -### Bonus `` +### `` -The `useFormStatus` hook data exposed via render prop: +Render children when the action is pending: ```tsx -import { FormStatus } from "react-form-action/client"; +import { Action, Pending } from "react-form-action/client"; -// alleviates the need to create a separate component using the useFormStatus hook +import { Spinner } from "./components"; return function MyForm() { return ( -
- - {({ pending }) => ( - - )} - -
+ + {/* 👉 Unlike the React.useFormStatus() hook, we don't need here the
element at all. */} + + + + ); }; ``` diff --git a/package-lock.json b/package-lock.json index 625e629..a6d85cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-form-action", - "version": "1.2.3", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-form-action", - "version": "1.2.3", + "version": "2.0.0", "license": "MIT", "devDependencies": { "@testing-library/dom": "^10.4.0", diff --git a/package.json b/package.json index f4a4bb8..b21e6cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-form-action", - "version": "1.2.3", + "version": "2.0.0", "description": "State management helpers for the react form actions.", "repository": { "type": "git", diff --git a/src/Action.test.tsx b/src/Action.test.tsx new file mode 100644 index 0000000..48f9458 --- /dev/null +++ b/src/Action.test.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { describe, test, expect } from "vitest"; +import { userEvent } from "@testing-library/user-event"; +import { act, render, screen } from "@testing-library/react"; +import { z } from "zod"; +import { Action } from "./Action"; +import { formAction } from "./formAction"; +import { ActionForm } from "./ActionForm"; + +import { createComponents } from "./createComponents"; + +describe("Action", () => { + test("it enables form to consume action via context", async () => { + const subscribeAction = formAction + .input( + z.object({ + email: z.string().email(), + }) + ) + .run(async () => { + return null; + }); + + const { FieldError } = createComponents(subscribeAction); + + function SubscribeForm() { + return ( + + + + +