From fcdcc6e151cc95d36140ae2366ae3c52903e32c2 Mon Sep 17 00:00:00 2001 From: Miroslav Petrik Date: Tue, 17 Dec 2024 13:25:55 +0100 Subject: [PATCH 01/10] feat(createForm): Form with embedded context --- src/Form.tsx | 4 ++-- src/createForm.test.tsx | 51 +++++++++++++++++++++++++++++++++++++++++ src/createForm.tsx | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 src/createForm.test.tsx create mode 100644 src/createForm.tsx diff --git a/src/Form.tsx b/src/Form.tsx index b0c46d2..e47e5e5 100644 --- a/src/Form.tsx +++ b/src/Form.tsx @@ -15,7 +15,7 @@ import { useActionState } from "react"; export type FormStateProps = { action: ( state: FormState, - payload: Payload, + payload: Payload ) => Promise>; initialData: Data; permalink?: string; @@ -76,7 +76,7 @@ export function Form({ const [state, formAction, isPending] = useActionState( action, initial(initialData), - permalink, + permalink ); const [, startTransition] = useTransition(); diff --git a/src/createForm.test.tsx b/src/createForm.test.tsx new file mode 100644 index 0000000..9e04dfe --- /dev/null +++ b/src/createForm.test.tsx @@ -0,0 +1,51 @@ +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 { createForm } from "./createForm"; +import { formAction } from "./formAction"; + +describe("createForm", () => { + describe("Pending", () => { + test("it renders children when the action is pending", async () => { + const signUp = formAction + .input( + z.object({ + email: z.string().email(), + }) + ) + .run(async () => { + await new Promise(() => { + /* never resolve */ + }); + return null; + }); + + const { Form, Pending } = createForm(signUp); + + function SignUpForm() { + 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"; @@ -105,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"; @@ -170,61 +258,21 @@ export const updateUser = createFormAction< ); ``` -### `
` Component +### Action Context -```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"; - -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} - )} - - - - )} -
- ); -} -``` - -### Context Form +The `` components enables you to access your `action`'s state with the `useActionContext()` hook: ```tsx -// Example route: /app/auth/signup/SignUpForm.tsx +// 👉 Define standalone client form component (e.g. /app/auth/signup/SignUpForm.tsx) "use client"; -import { createForm, Pending, useFormContext } from "react-form-action/client"; -import { signupAction } from "./actions"; +import { Action, Form, useActionContext } from "react-form-action/client"; +import type { PropsWithChildren } from "react"; -// This call will succeed only in client component -const { Form } = createForm(signupAction); +import { signupAction } from "./action"; -function Error() { - // read any state from the FormContext: +function Pending({ children }: PropsWithChildren) { + // read any state from the ActionContext: const { error, data, @@ -233,36 +281,64 @@ function Error() { isFailure, isInvalid, isSuccess, - isInitial - } = useFormContext(); + isInitial, + } = useActionContext(); - return isFailure && "Failed to submit". // use the error somehow + return isPending && children; } -// render this form on your RSC page (/app/auth/signup/page.tsx) +// 💡 render this form on your RSC page (/app/auth/signup/page.tsx) export function SignupForm() { - // This Form does not use render props. return ( -
- - + + + + + + {/* 🎆 Read the pending state outside the
*/} - {/* This renders only when the action is pending. */} + {/* This renders only when the action is pending. 😎 */}

Please wait...

-
+
); } ``` -### `` Component +### `
` Component -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: +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 { Form, ZodFieldError } from "react-form-action/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 signUp = authAction .input( @@ -280,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 a0120e1..a6d85cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-form-action", - "version": "1.5.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-form-action", - "version": "1.5.0", + "version": "2.0.0", "license": "MIT", "devDependencies": { "@testing-library/dom": "^10.4.0", diff --git a/package.json b/package.json index f915d43..b21e6cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-form-action", - "version": "1.5.0", + "version": "2.0.0", "description": "State management helpers for the react form actions.", "repository": { "type": "git", diff --git a/src/Action.tsx b/src/Action.tsx index c44cd5e..996a886 100644 --- a/src/Action.tsx +++ b/src/Action.tsx @@ -10,6 +10,7 @@ import type { FailureState, FormAction, } from "./createFormAction"; +import { initial } from "./createFormAction"; import { useActionState } from "react"; export type ActionProps = PropsWithChildren<{ @@ -18,10 +19,6 @@ export type ActionProps = PropsWithChildren<{ permalink?: string; }>; -function initial(data: Data): InitialState { - return { type: "initial", data, error: null, validationError: null }; -} - type ActionStatusFlags< T extends ActionState["type"] | unknown = unknown, > = { @@ -70,7 +67,9 @@ const ActionContext = createContext(null); /** * A hook to consume the form action state from the context. */ -export function useActionContext() { +export function useActionContext( + action?: FormAction +) { const ctx = use(ActionContext); if (!ctx) { diff --git a/src/createComponents.tsx b/src/createComponents.tsx index faf5742..f4cdafe 100644 --- a/src/createComponents.tsx +++ b/src/createComponents.tsx @@ -17,11 +17,7 @@ export function createComponents< function FieldError({ name, }: PropsWithChildren<{ name?: "" | InferZodErrorPaths }>) { - const { isInvalid, validationError } = useActionContext< - Data, - Error, - ValidationError - >(); + const { isInvalid, validationError } = useActionContext(action); return isInvalid && ; } diff --git a/src/createFormAction.ts b/src/createFormAction.ts index c2bef31..f351cc5 100644 --- a/src/createFormAction.ts +++ b/src/createFormAction.ts @@ -42,6 +42,10 @@ export type FormAction< payload: Payload ) => Promise>; +export function initial(data: Data): InitialState { + return { type: "initial", data, error: null, validationError: null }; +} + export function createFormAction< Data, Error = Data, diff --git a/src/formAction.test.ts b/src/formAction.test.ts index 354a334..e5400a1 100644 --- a/src/formAction.test.ts +++ b/src/formAction.test.ts @@ -1,14 +1,7 @@ import { it, describe, vi, expect } from "vitest"; -import { - AnyZodObject, - ZodEffects, - ZodObject, - ZodType, - ZodTypeAny, - z, -} from "zod"; +import { z } from "zod"; import { formAction } from "./formAction"; -import { initial } from "./Form"; +import { initial } from "./createFormAction"; import { zfd } from "zod-form-data"; describe("formAction", () => {