Skip to content

Commit

Permalink
Merge pull request #11 from MiroslavPetrik/context
Browse files Browse the repository at this point in the history
Context
  • Loading branch information
MiroslavPetrik authored Dec 23, 2024
2 parents e9db4f5 + e3ddaf9 commit f335b33
Show file tree
Hide file tree
Showing 16 changed files with 570 additions and 359 deletions.
271 changes: 196 additions & 75 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 `<Form />`**
**React Context access with the `<Action action={myFormAction} />` component**

-`<Form />` 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 `<Form />` component**

- ✅ Reads the `action` from the `<Action />` context.
- ✅ Opt-out from the default form reset after action submit.

## Install

Expand All @@ -29,10 +35,95 @@ npm i react-form-action zod-form-data
<img alt="NPM Version" src="https://img.shields.io/npm/v/react-form-action?style=for-the-badge&labelColor=24292e">
</a>

## 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 (
<Action action={subscribeAction} initialData="">
<SuccessMessage />
<Form>
<input name="email" />
{/*💡 The name prop supports autocompletion */}
<FieldError name="email" />
</Form>
<SubmitButton />
<Pending>Please wait...</Pending>
</Action>
);
}

function SuccessMessage() {
// Pass action to cast the type of the "data" value
const { isSuccess, data } = useActionContext(subscribeAction);

return isSuccess && <p>Email {data} was registered.</p>;
}

function SubmitButton() {
// no need for action when reading a generic value
const { isPending } = useActionContext();

return (
<button type="submit" disabled={isPending}>
{isPending ? "Submitting..." : "Submit"}
</button>
);
}
```

#### 3️⃣ Render the form on a Page

```tsx
// app/subscibe/page.tsx

import { SubscribeForm } from "./SubscribeForm";

function SuccessMessage() {}

export function Page() {
return <SubscribeForm />;
}
```

## 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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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!
Expand All @@ -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";
Expand Down Expand Up @@ -161,59 +254,91 @@ export const updateUser = createFormAction<

return failure({ message: "Failed to update user." });
}
},
}
);
```

### Action Context

The `<Action>` 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 (
<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>
);
}
```

### `<Form>` Component

The `<form>` 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 (
<Form action={updateUser} initialData="foobar">
{({
error,
data,
validationError,
isPending,
isFailure,
isInvalid,
isSuccess,
isInitial,
}) => (
<>
{/* safely access the data or error by checking the mutually exclusive boolean flags: */}
{isSuccess && <p className="success-message">{data}</p>}
{isFailure && <p className="error-message">{error.message}</p>}
<input type="text" name="name" disabled={isPending} />
{isInvalid && (
<span className="input-error">{validationError.name}</span>
)}

<button disabled={isPending}>
{isPending ? "Submitting..." : "Submit"}
</button>
</>
)}
</Form>
<Action action={updateUser}>
<Form autoReset>{/* ... */}</Form>
</Action>
);
}
```

### `<ZodFieldError>` 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 `<ZodFieldError>` component:
### `<FielError>` 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(
Expand All @@ -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 (
<Form action={signUp} initialData="">
{({ 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"
*/}
<ZodFieldError errors={validationError} />
{/* Access fields by their name: */}
<ZodFieldError errors={validationError} name="password" />
{/* Access nested fields by dot access notation: */}
<ZodFieldError errors={validationError} name="user.email" />
</>
)
}
</Form>
<Action action={signUp} 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>
);
}
```

### Bonus `<FormStatus>`
### `<Pending>`

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";

// <FormStatus> alleviates the need to create a separate <SubmitButton> component using the useFormStatus hook
import { Spinner } from "./components";

return function MyForm() {
return (
<form action={action}>
<FormStatus>
{({ pending }) => (
<button type="submit">{pending ? "Submitting..." : "Submit"} </button>
)}
</FormStatus>
</form>
<Action action={action}>
{/* 👉 Unlike the React.useFormStatus() hook, we don't need here the <form> element at all. */}
<Pending>
<Spinner />
</Pending>
</Action>
);
};
```
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading

0 comments on commit f335b33

Please sign in to comment.