react-form-action 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # react-form-action
2
2
 
3
- Typesafe success & error state control for Next.js v14 form actions.
3
+ End-to-end typesafe success, error & validation state control for Next.js 14 form actions.
4
+
5
+ ## Features
6
+
7
+ **Action Creator**
8
+
9
+ - ✅ Provides `"invalid" | "success" | "failure"` response objects.
10
+ - ✅ Define generic payload for each of the response type.
11
+
12
+ **Form Action builder**
13
+
14
+ - ✅ tRPC-like builder API for `.input(zodSchema)` & context `.use(middleware)`
15
+ - ✅ Parses `formData` with [`zod-form-data`](https://www.npmjs.com/package/zod-form-data)
16
+
17
+ **Stateful `<Form />`**
18
+
19
+ - ✅ `<Form />` component reads the action's response.
20
+ - ✅ Computes progress meta-state like `isInvalid`, `isSuccess` and more.
4
21
 
5
22
  ## Install
6
23
 
@@ -10,57 +27,124 @@ npm i react-form-action
10
27
 
11
28
  ## Usage
12
29
 
30
+ ### `formAction` builder
31
+
32
+ ```ts
33
+ // app/actions/auth.ts
34
+ "use server";
35
+
36
+ import { formAction } from "react-form-action";
37
+ import { z } from "zod";
38
+ import { cookies } from "next/headers";
39
+
40
+ const emailSchema = z.object({ email: z.string().email() });
41
+
42
+ const i18nMiddleware = async () => {
43
+ const { t } = await useTranslation("auth", cookies().get("i18n")?.value);
44
+ // will be added to context
45
+ return { t };
46
+ };
47
+
48
+ const authAction = formAction
49
+ .input(emailschema)
50
+ .use(i18nMiddleware)
51
+ .use(async ({ ctx: { t } }) =>
52
+ console.log("🎉 context enhanced by previous middlewares 🎉", t)
53
+ )
54
+ .error(({ error }) => {
55
+ if (error instanceof DbError) {
56
+ return error.custom.code;
57
+ } else {
58
+ // unknown error
59
+ // default Next.js error handling (error.js boundary)
60
+ throw error;
61
+ }
62
+ });
63
+
64
+ export const resendVerifyEmailAction = authAction.run(
65
+ async ({ ctx: { t }, input: { email } }) => {
66
+ // do custom work
67
+ await db.resendVerificationEmail({ email });
68
+
69
+ // return translated success message
70
+ return t("verificationEmail.success");
71
+ }
72
+ );
73
+
74
+ export const signUp = authAction
75
+ // extend the input
76
+ .input(z.object({ password: z.string() }))
77
+ .run(async ({ ctx: { t }, input: { email, password } }) => {
78
+ // do custom work
79
+ await db.signupWithEmailAndPassword({ email, password });
80
+
81
+ // return translated success message
82
+ return t("signUp.success");
83
+ });
84
+ ```
85
+
13
86
  ### Server Action (usable in Next.js)
14
87
 
15
88
  ```ts
16
- // actions/updateUser.ts
17
89
  "use server";
90
+
18
91
  import { createFormAction } from "react-form-action";
92
+ import { z } from "zod";
19
93
 
20
94
  // Define custom serializable error & success data types
21
95
  type ErrorData = {
22
96
  message: string;
23
- validation?: boolean;
24
97
  };
25
98
 
26
- type SuccessData = string;
99
+ type SuccessData = {
100
+ message: string;
101
+ };
27
102
 
28
- export const updateUser = createFormAction<SuccessData, ErrorData>(
29
- ({ success, failure }) =>
30
- // success and failure helper functions create wrappers for success & error data respectively
31
- async (prevState, formData) => {
32
- if (prevState.type === "initial") {
33
- // use the initialData passed to <Form /> here
34
- // prevState.data === "foobar"
35
- }
103
+ type ValiationError = {
104
+ name?: string;
105
+ };
36
106
 
37
- try {
38
- const { name } = updateUserSchema.parse({
39
- name: formData.get("name"),
40
- });
107
+ const updateUserSchema = z.object({ name: z.string() });
108
+
109
+ export const updateUser = createFormAction<
110
+ SuccessData,
111
+ ErrorData,
112
+ ValiationError
113
+ >(({ success, failure, invalid }) =>
114
+ // success and failure helper functions create wrappers for success & error data respectively
115
+ async (prevState, formData) => {
116
+ if (prevState.type === "initial") {
117
+ // use the initialData passed to <Form /> here
118
+ // prevState.data === "foobar"
119
+ }
41
120
 
42
- const user = await updateCurrentUser(name);
43
-
44
- if (user) {
45
- const successState = success("Your profile has been updated.");
46
- // {type: "success", data: "Your profile has been updated.", error: null }
47
- return successState;
48
- } else {
49
- const errorState = failure({ message: "No current user." });
50
- // {type: "error", data: null, error: { message: "No current user." } }
51
- return errorState;
52
- }
53
- } catch (error) {
54
- if (error instanceof ZodError) {
55
- return failure({
56
- validation: true,
57
- message: error.issues[0]?.message ?? "Validation error",
58
- });
59
- }
60
-
61
- return failure({ message: "Failed to update user." });
121
+ try {
122
+ const { name } = updateUserSchema.parse({
123
+ name: formData.get("name"),
124
+ });
125
+
126
+ const user = await updateCurrentUser(name);
127
+
128
+ if (user) {
129
+ // {type: "success", data: "Your profile has been updated.", error: null, validationError: null}
130
+ return success({
131
+ message: "Your profile has been updated.",
132
+ });
133
+ } else {
134
+ // {type: "error", data: null, error: { message: "No current user." }, validationError: null}
135
+ return failure({ message: "No current user." });
136
+ }
137
+ } catch (error) {
138
+ if (error instanceof ZodError) {
139
+ // {type: "invalid", data: null, error: null, validationError: {name: "Invalid input"}}
140
+ return invalid({
141
+ name: error.issues[0]?.message ?? "Validation error",
142
+ });
62
143
  }
144
+
145
+ return failure({ message: "Failed to update user." });
63
146
  }
147
+ }
64
148
  );
65
149
  ```
66
150
 
@@ -76,18 +160,23 @@ import { updateUser } from "@/actions";
76
160
  export function UpdateUserForm() {
77
161
  return (
78
162
  <Form action={updateUser} initialData="foobar">
79
- {({ error, data, isPending, isFailure, isSuccess, isInitial }) => (
163
+ {({
164
+ error,
165
+ data,
166
+ validationError,
167
+ isPending,
168
+ isFailure,
169
+ isInvalid,
170
+ isSuccess,
171
+ isInitial,
172
+ }) => (
80
173
  <>
81
174
  {/* safely access the data or error by checking the mutually exclusive boolean flags: */}
82
175
  {isSuccess && <p className="success-message">{data}</p>}
83
-
84
- {isFailure && !error.validation && (
85
- <p className="error-message">{error.message}</p>
86
- )}
87
-
176
+ {isFailure && <p className="error-message">{error.message}</p>}
88
177
  <input type="text" name="name" disabled={isPending} />
89
- {isFailure && error.validation && (
90
- <span className="input-error">{error.message}</span>
178
+ {isInvalid && (
179
+ <span className="input-error">{validationError.name}</span>
91
180
  )}
92
181
 
93
182
  <button disabled={isPending}>
package/dist/client.d.mts CHANGED
@@ -1,22 +1,24 @@
1
1
  import * as React from 'react';
2
2
  import React__default, { FormHTMLAttributes } from 'react';
3
3
  import { RenderProp } from 'react-render-prop-type';
4
- import { InitialState, FailureState, SuccessState, FormState } from './index.mjs';
4
+ import { b as InitialState, I as InvalidState, a as FailureState, S as SuccessState, F as FormState } from './createFormAction-3a51310f.js';
5
5
  import { useFormStatus } from 'react-dom';
6
6
 
7
- type FormStateProps<Data, Error, Payload> = {
8
- action: (state: FormState<Data, Error>, payload: Payload) => Promise<FormState<Data, Error>>;
7
+ type FormStateProps<Data, ValidationError, Error, Payload> = {
8
+ action: (state: FormState<Data, ValidationError, Error>, payload: Payload) => Promise<FormState<Data, ValidationError, Error>>;
9
9
  initialData: Data;
10
10
  permalink?: string;
11
11
  };
12
- type FormMetaState<T extends FormState<unknown, unknown>> = T & {
12
+ declare function initial<Data>(data: Data): InitialState<Data>;
13
+ type FormMetaState<T extends FormState<unknown, unknown, unknown>> = T & {
13
14
  isPending: boolean;
14
15
  isInitial: T["type"] extends "initial" ? true : false;
15
16
  isFailure: T["type"] extends "failure" ? true : false;
16
17
  isSuccess: T["type"] extends "success" ? true : false;
18
+ isInvalid: T["type"] extends "invalid" ? true : false;
17
19
  };
18
- declare function Form<Data, Error>({ children, action, initialData, permalink, ...props }: Omit<FormHTMLAttributes<HTMLFormElement>, "action" | "children"> & FormStateProps<Data, Error, FormData> & RenderProp<FormMetaState<InitialState<Data>> | FormMetaState<FailureState<Error>> | FormMetaState<SuccessState<Data>>>): React__default.JSX.Element;
20
+ declare function Form<Data, ValidationError, Error>({ children, action, initialData, permalink, ...props }: Omit<FormHTMLAttributes<HTMLFormElement>, "action" | "children"> & FormStateProps<Data, ValidationError, Error, FormData> & RenderProp<FormMetaState<InitialState<Data>> | FormMetaState<InvalidState<Error>> | FormMetaState<FailureState<Error>> | FormMetaState<SuccessState<Data>>>): React__default.JSX.Element;
19
21
 
20
22
  declare function FormStatus({ children, }: RenderProp<ReturnType<typeof useFormStatus>>): React.ReactNode;
21
23
 
22
- export { Form, FormStatus };
24
+ export { Form, FormStatus, initial };
package/dist/client.mjs CHANGED
@@ -1 +1 @@
1
- "use client";import a from"react";import{useFormState as l}from"react-dom";import{useFormStatus as u}from"react-dom";function r({children:t}){return t(u())}function c(t){return{type:"initial",data:t,error:null}}function D({children:t,action:o,initialData:i,permalink:n,...s}){let[e,m]=l(o,c(i),n);return a.createElement("form",{action:m,...s},a.createElement(r,null,({pending:p})=>t({...e,isPending:p,isInitial:e.type==="initial",isFailure:e.type==="failure",isSuccess:e.type==="success"})))}export{D as Form,r as FormStatus};
1
+ "use client";import a from"react";import{useFormState as u}from"react-dom";import{useFormStatus as p}from"react-dom";function e({children:t}){return t(p())}function S(t){return{type:"initial",data:t,error:null,validationError:null}}function E({children:t,action:o,initialData:i,permalink:n,...s}){let[r,m]=u(o,S(i),n);return a.createElement("form",{action:m,...s},a.createElement(e,null,({pending:l})=>t({...r,isPending:l,isInitial:r.type==="initial",isInvalid:r.type==="invalid",isFailure:r.type==="failure",isSuccess:r.type==="success"})))}export{E as Form,e as FormStatus,S as initial};
@@ -0,0 +1,32 @@
1
+ type InitialState<T> = {
2
+ type: "initial";
3
+ data: T;
4
+ error: null;
5
+ validationError: null;
6
+ };
7
+ type InvalidState<T> = {
8
+ type: "invalid";
9
+ data: null;
10
+ error: null;
11
+ validationError: T;
12
+ };
13
+ type FailureState<T> = {
14
+ type: "failure";
15
+ data: null;
16
+ error: T;
17
+ validationError: null;
18
+ };
19
+ type SuccessState<T> = {
20
+ type: "success";
21
+ data: T;
22
+ error: null;
23
+ validationError: null;
24
+ };
25
+ type FormState<Data, Error, ValidationError = Record<string, never>> = InitialState<Data> | InvalidState<ValidationError> | FailureState<Error> | SuccessState<Data>;
26
+ declare function createFormAction<Data, Error = Data, ValidationError = Record<string, never>, Payload = FormData>(formAction: (params: {
27
+ success: (data: Data) => SuccessState<Data>;
28
+ failure: (error: Error) => FailureState<Error>;
29
+ invalid: (validationError: ValidationError) => InvalidState<ValidationError>;
30
+ }) => (state: FormState<Data, Error, ValidationError>, payload: Payload) => Promise<FormState<Data, Error, ValidationError>>): (state: FormState<Data, Error, ValidationError>, payload: Payload) => Promise<FormState<Data, Error, ValidationError>>;
31
+
32
+ export { FormState as F, InvalidState as I, SuccessState as S, FailureState as a, InitialState as b, createFormAction as c };
package/dist/index.d.mts CHANGED
@@ -1,22 +1,53 @@
1
- type InitialState<T> = {
2
- type: "initial";
3
- data: T;
4
- error: null;
5
- };
6
- type FailureState<T> = {
7
- type: "failure";
8
- data: null;
9
- error: T;
10
- };
11
- type SuccessState<T> = {
12
- type: "success";
13
- data: T;
14
- error: null;
1
+ import { F as FormState, a as FailureState, S as SuccessState, I as InvalidState } from './createFormAction-3a51310f.js';
2
+ export { b as InitialState, c as createFormAction } from './createFormAction-3a51310f.js';
3
+ import { z, ZodRawShape, ZodObject, objectUtil, ZodSchema } from 'zod';
4
+
5
+ type Flatten<T> = Identity<{
6
+ [K in keyof T]: T[K];
7
+ }>;
8
+ type Identity<T> = T;
9
+ type InitialContext = Record<"formData", FormData>;
10
+ /**
11
+ * Action without schema has no input.
12
+ */
13
+ type Action<Data, Context> = (params: {
14
+ ctx: Context;
15
+ }) => Promise<Data>;
16
+ /**
17
+ * Action with schema will get parsed formData as the input.
18
+ */
19
+ type SchemaAction<Data, Context, Schema extends ZodSchema> = (params: {
20
+ ctx: Context;
21
+ input: z.infer<Schema>;
22
+ }) => Promise<Data>;
23
+ type EmptyZodObject = ZodObject<ZodRawShape>;
24
+ type FormActionBuilder<Schema extends EmptyZodObject, Err = Error, Context = InitialContext> = {
25
+ input: <T extends ZodRawShape>(schema: ZodObject<T>) => FormActionBuilder<ZodObject<objectUtil.extendShape<T, Schema["shape"]>>, Err, Context>;
26
+ /**
27
+ * A finishing call on the builder completing your action.
28
+ * @param action async function to execute. It will receive parsed formData as input, when the builder was initialized with a schema.
29
+ * @returns runnable server action exportable from a module.
30
+ */
31
+ run: Schema extends undefined ? <Data>(action: Action<Data, Context>) => (state: FormState<Data, Err>, payload: FormData) => Promise<Flatten<FailureState<Err> | SuccessState<Data>>> : <Data>(action: SchemaAction<Data, Context, Schema>) => (state: FormState<Data, Err, z.inferFlattenedErrors<Schema>>, payload: FormData) => Promise<Flatten<InvalidState<z.inferFlattenedErrors<Schema>> | FailureState<Err> | SuccessState<Data>>>;
32
+ /**
33
+ * A chainable context enhancing helper.
34
+ * @param middleware A function which receives the current context, and returns an object which will be added to the context.
35
+ * @returns FormActionBuilder
36
+ */
37
+ use: <NewContext extends Record<string, unknown>>(middleware: ({ ctx }: {
38
+ ctx: Context;
39
+ }) => Promise<NewContext>) => FormActionBuilder<Schema, Err, Context & NewContext>;
40
+ /**
41
+ * A chainable error handler to handle errors thrown while running the action passed to .run().
42
+ * It does not receive errors thrown from the context/middleware.
43
+ * @param processError A function which receives action's errors.
44
+ * @returns FormActionBuilder
45
+ */
46
+ error: <Err>(processError: (params: {
47
+ error: unknown;
48
+ ctx: Context;
49
+ }) => Err) => FormActionBuilder<Schema, Err, Context>;
15
50
  };
16
- type FormState<Data, Error> = InitialState<Data> | FailureState<Error> | SuccessState<Data>;
17
- declare function createFormAction<Data, Error = Data, Payload = FormData>(formAction: (params: {
18
- success: (data: Data) => SuccessState<Data>;
19
- failure: (data: Error) => FailureState<Error>;
20
- }) => (state: FormState<Data, Error>, payload: Payload) => Promise<FormState<Data, Error>>): (state: FormState<Data, Error>, payload: Payload) => Promise<FormState<Data, Error>>;
51
+ declare const formAction: FormActionBuilder<z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>, Error, InitialContext>;
21
52
 
22
- export { FailureState, FormState, InitialState, SuccessState, createFormAction };
53
+ export { FailureState, FormState, InvalidState, SuccessState, formAction };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- function u(t){function r(a){return{type:"success",data:a,error:null}}function e(a){return{type:"failure",data:null,error:a}}return(a,o)=>t({success:r,failure:e})(a,o)}export{u as createFormAction};
1
+ function p(a){function o(e){return{type:"success",data:e,error:null,validationError:null}}function n(e){return{type:"failure",data:null,error:e,validationError:null}}function l(e){return{type:"invalid",data:null,error:null,validationError:e}}return(e,s)=>a({success:o,failure:n,invalid:l})(e,s)}import{z as D}from"zod";import{zfd as h}from"zod-form-data";function u(a,o=[],n){async function l(t){let r={formData:t};for(let i of o){let d=await i({ctx:r});r={...r,...d}}return r}let e=t=>p(({success:r,failure:i})=>async(d,x)=>{let m=await l(x);try{return r(await t({ctx:m}))}catch(c){if(n)return i(n({error:c,ctx:m}));throw c}}),s=t=>p(({success:r,failure:i,invalid:d})=>{let x=h.formData(a);return async(m,c)=>{let E=await l(c),S=x.safeParse(c);if(!S.success)return d(S.error.flatten());let y=S.data;try{return r(await t({input:y,ctx:E}))}catch(C){if(n)return i(n({error:C,ctx:E}));throw C}}});return{input(t){let r=a.merge(t);return u(r,o)},use(t){return u(a,[...o,t])},error(t){return u(a,o,t)},run:Object.keys(a.shape).length===0?e:s}}var j=u(D.object({}));export{p as createFormAction,j as formAction};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-form-action",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "State management helpers for the react form actions.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,7 +12,8 @@
12
12
  "/dist"
13
13
  ],
14
14
  "scripts": {
15
- "build": "tsup"
15
+ "build": "tsup",
16
+ "test": "vitest"
16
17
  },
17
18
  "exports": {
18
19
  ".": {
@@ -30,18 +31,20 @@
30
31
  },
31
32
  "keywords": [
32
33
  "react",
33
- "form",
34
- "action",
34
+ "form-action",
35
+ "react-form-action",
35
36
  "next",
37
+ "next-form-action",
36
38
  "nextjs",
37
- "server",
38
- "action"
39
+ "server-action"
39
40
  ],
40
41
  "author": "Miroslav Petrik",
41
42
  "license": "MIT",
42
43
  "peerDependencies": {
43
44
  "react": ">=18.2",
44
- "react-dom": ">=18.2"
45
+ "react-dom": ">=18.2",
46
+ "zod": "^3",
47
+ "zod-form-data": "^2"
45
48
  },
46
49
  "devDependencies": {
47
50
  "@types/react": "^18.2.37",
@@ -49,6 +52,9 @@
49
52
  "next": "^14.0.2",
50
53
  "react-render-prop-type": "^0.1.0",
51
54
  "tsup": "^7.2.0",
52
- "typescript": "^5.2.2"
55
+ "typescript": "^5.2.2",
56
+ "vitest": "^1.2.2",
57
+ "zod": "^3.22.4",
58
+ "zod-form-data": "^2.0.2"
53
59
  }
54
60
  }