@firtoz/router-toolkit 5.0.0 → 5.1.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 +3 -1
- package/package.json +9 -9
- package/src/formAction.ts +160 -10
- package/src/types/HrefArgs.ts +21 -6
- package/src/useCachedFetch.ts +2 -2
- package/src/useDynamicFetcher.ts +210 -2
- package/src/useDynamicSubmitter.tsx +242 -3
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
Type-safe React Router 7 framework mode helpers with enhanced fetching, form submission, and state management for React Router 7 framework mode.
|
|
8
8
|
|
|
9
|
+
> **⚠️ Early WIP Notice:** This package is in very early development and is **not production-ready**. It is TypeScript-only and may have breaking changes. While I (the maintainer) have limited time, I'm open to PRs for features, bug fixes, or additional support (like JS builds). Please feel free to try it out and contribute! See [CONTRIBUTING.md](../../CONTRIBUTING.md) for details.
|
|
10
|
+
|
|
9
11
|
## Features
|
|
10
12
|
|
|
11
13
|
- ✅ **Type-safe routing** - Full TypeScript support with React Router 7 framework mode
|
|
@@ -1175,7 +1177,7 @@ export const route: RoutePath<"/create-user"> = "/create-user";
|
|
|
1175
1177
|
|
|
1176
1178
|
export const formSchema = z.object({
|
|
1177
1179
|
name: z.string().min(1),
|
|
1178
|
-
email: z.
|
|
1180
|
+
email: z.email(),
|
|
1179
1181
|
});
|
|
1180
1182
|
|
|
1181
1183
|
interface ValidationError {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/router-toolkit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "Type-safe React Router 7 framework mode helpers with enhanced fetching, form submission, and state management",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -55,10 +55,10 @@
|
|
|
55
55
|
"url": "https://github.com/firtoz/fullstack-toolkit/issues"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@firtoz/maybe-error": "^1.5.
|
|
59
|
-
"react": "^19.2.
|
|
60
|
-
"react-router": "^7.
|
|
61
|
-
"zod": "^4.
|
|
58
|
+
"@firtoz/maybe-error": "^1.5.1",
|
|
59
|
+
"react": "^19.2.3",
|
|
60
|
+
"react-router": "^7.12.0",
|
|
61
|
+
"zod": "^4.3.5"
|
|
62
62
|
},
|
|
63
63
|
"engines": {
|
|
64
64
|
"node": ">=18.0.0"
|
|
@@ -70,10 +70,10 @@
|
|
|
70
70
|
"zod-form-data": "^3.0.1"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
|
-
"@testing-library/react": "^16.3.
|
|
73
|
+
"@testing-library/react": "^16.3.1",
|
|
74
74
|
"@types/jsdom": "^27.0.0",
|
|
75
|
-
"@types/react": "^19.2.
|
|
76
|
-
"bun-types": "^1.3.
|
|
77
|
-
"jsdom": "^27.0
|
|
75
|
+
"@types/react": "^19.2.8",
|
|
76
|
+
"bun-types": "^1.3.6",
|
|
77
|
+
"jsdom": "^27.4.0"
|
|
78
78
|
}
|
|
79
79
|
}
|
package/src/formAction.ts
CHANGED
|
@@ -4,26 +4,176 @@
|
|
|
4
4
|
* This module provides a wrapper for React Router actions that handles form data validation
|
|
5
5
|
* using Zod schemas and provides structured error handling with MaybeError.
|
|
6
6
|
*
|
|
7
|
+
* ## Overview
|
|
8
|
+
*
|
|
9
|
+
* `formAction` is designed to work seamlessly with `useDynamicSubmitter` and `useDynamicFetcher`
|
|
10
|
+
* to provide end-to-end type safety for your React Router forms.
|
|
11
|
+
*
|
|
7
12
|
* @example
|
|
13
|
+
* ### Basic Route Setup (`app/routes/auth.login.tsx`)
|
|
14
|
+
*
|
|
8
15
|
* ```typescript
|
|
9
16
|
* import { z } from "zod";
|
|
10
|
-
* import { formAction } from "@firtoz/router-toolkit";
|
|
11
|
-
* import { success } from "@firtoz/maybe-error";
|
|
17
|
+
* import { formAction, type RoutePath } from "@firtoz/router-toolkit";
|
|
18
|
+
* import { success, fail } from "@firtoz/maybe-error";
|
|
19
|
+
*
|
|
20
|
+
* // 1. Export the route path for type inference
|
|
21
|
+
* export const route: RoutePath<"/auth/login"> = "/auth/login";
|
|
12
22
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
23
|
+
* // 2. Define your form schema with Zod
|
|
24
|
+
* export const formSchema = z.object({
|
|
25
|
+
* email: z.string().email("Please enter a valid email"),
|
|
26
|
+
* password: z.string().min(8, "Password must be at least 8 characters"),
|
|
27
|
+
* rememberMe: z.boolean().optional().default(false),
|
|
16
28
|
* });
|
|
17
29
|
*
|
|
30
|
+
* // 3. Create the action with formAction
|
|
18
31
|
* export const action = formAction({
|
|
19
|
-
* schema,
|
|
20
|
-
* handler: async (
|
|
21
|
-
* // data is fully typed
|
|
22
|
-
*
|
|
23
|
-
*
|
|
32
|
+
* schema: formSchema,
|
|
33
|
+
* handler: async ({ request }, data) => {
|
|
34
|
+
* // data is fully typed: { email: string, password: string, rememberMe: boolean }
|
|
35
|
+
* try {
|
|
36
|
+
* const user = await authenticateUser(data.email, data.password);
|
|
37
|
+
* if (data.rememberMe) {
|
|
38
|
+
* await createPersistentSession(user.id);
|
|
39
|
+
* }
|
|
40
|
+
* return success({ user });
|
|
41
|
+
* } catch (error) {
|
|
42
|
+
* return fail("Invalid email or password");
|
|
43
|
+
* }
|
|
44
|
+
* },
|
|
45
|
+
* });
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ### Using with useDynamicSubmitter
|
|
50
|
+
*
|
|
51
|
+
* The route above can be used with `useDynamicSubmitter` for type-safe form submissions:
|
|
52
|
+
*
|
|
53
|
+
* ```tsx
|
|
54
|
+
* import { useDynamicSubmitter } from "@firtoz/router-toolkit";
|
|
55
|
+
*
|
|
56
|
+
* function LoginForm() {
|
|
57
|
+
* const submitter = useDynamicSubmitter<typeof import("./auth.login")>("/auth/login");
|
|
58
|
+
*
|
|
59
|
+
* // Option 1: Submit as JSON (recommended for programmatic use)
|
|
60
|
+
* const handleLoginJson = async () => {
|
|
61
|
+
* await submitter.submitJson(
|
|
62
|
+
* { email: "user@example.com", password: "secret123", rememberMe: true },
|
|
63
|
+
* { method: "POST" }
|
|
64
|
+
* );
|
|
65
|
+
* };
|
|
66
|
+
*
|
|
67
|
+
* // Option 2: Use the Form component
|
|
68
|
+
* return (
|
|
69
|
+
* <submitter.Form method="POST">
|
|
70
|
+
* <input name="email" type="email" placeholder="Email" />
|
|
71
|
+
* <input name="password" type="password" placeholder="Password" />
|
|
72
|
+
* <label>
|
|
73
|
+
* <input name="rememberMe" type="checkbox" /> Remember me
|
|
74
|
+
* </label>
|
|
75
|
+
* <button disabled={submitter.state !== "idle"}>
|
|
76
|
+
* {submitter.state === "submitting" ? "Logging in..." : "Login"}
|
|
77
|
+
* </button>
|
|
78
|
+
*
|
|
79
|
+
* {submitter.data && !submitter.data.success && (
|
|
80
|
+
* <div className="error">
|
|
81
|
+
* {submitter.data.error.type === "validation"
|
|
82
|
+
* ? "Please check your inputs"
|
|
83
|
+
* : submitter.data.error.type === "handler"
|
|
84
|
+
* ? submitter.data.error.error // "Invalid email or password"
|
|
85
|
+
* : "An unexpected error occurred"}
|
|
86
|
+
* </div>
|
|
87
|
+
* )}
|
|
88
|
+
* </submitter.Form>
|
|
89
|
+
* );
|
|
90
|
+
* }
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ### Combined loader + action route (`app/routes/admin.posts.$id.tsx`)
|
|
95
|
+
*
|
|
96
|
+
* You can combine `formAction` with a loader for full CRUD operations:
|
|
97
|
+
*
|
|
98
|
+
* ```typescript
|
|
99
|
+
* import { z } from "zod";
|
|
100
|
+
* import { formAction, type RoutePath } from "@firtoz/router-toolkit";
|
|
101
|
+
* import { success, fail } from "@firtoz/maybe-error";
|
|
102
|
+
* import type { LoaderFunctionArgs } from "react-router";
|
|
103
|
+
*
|
|
104
|
+
* export const route: RoutePath<"/admin/posts/:id"> = "/admin/posts/:id";
|
|
105
|
+
*
|
|
106
|
+
* // Loader for fetching data (used with useDynamicFetcher)
|
|
107
|
+
* export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|
108
|
+
* const post = await db.posts.findUnique({ where: { id: params.id } });
|
|
109
|
+
* return { post };
|
|
110
|
+
* };
|
|
111
|
+
*
|
|
112
|
+
* // Form schema for updates
|
|
113
|
+
* export const formSchema = z.object({
|
|
114
|
+
* title: z.string().min(1, "Title is required"),
|
|
115
|
+
* content: z.string().min(10, "Content must be at least 10 characters"),
|
|
116
|
+
* published: z.boolean().optional().default(false),
|
|
117
|
+
* });
|
|
118
|
+
*
|
|
119
|
+
* // Action for handling form submissions (used with useDynamicSubmitter)
|
|
120
|
+
* export const action = formAction({
|
|
121
|
+
* schema: formSchema,
|
|
122
|
+
* handler: async ({ params }, data) => {
|
|
123
|
+
* const updated = await db.posts.update({
|
|
124
|
+
* where: { id: params.id },
|
|
125
|
+
* data,
|
|
126
|
+
* });
|
|
127
|
+
* return success({ post: updated });
|
|
24
128
|
* },
|
|
25
129
|
* });
|
|
26
130
|
* ```
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ### Full CRUD component using both hooks
|
|
134
|
+
*
|
|
135
|
+
* ```tsx
|
|
136
|
+
* import { useDynamicFetcher, useDynamicSubmitter } from "@firtoz/router-toolkit";
|
|
137
|
+
* import { useEffect } from "react";
|
|
138
|
+
*
|
|
139
|
+
* function PostEditor({ postId }: { postId: string }) {
|
|
140
|
+
* // Fetch post data
|
|
141
|
+
* const fetcher = useDynamicFetcher<typeof import("./admin.posts.$id")>(
|
|
142
|
+
* "/admin/posts/:id",
|
|
143
|
+
* { id: postId }
|
|
144
|
+
* );
|
|
145
|
+
*
|
|
146
|
+
* // Submit updates
|
|
147
|
+
* const submitter = useDynamicSubmitter<typeof import("./admin.posts.$id")>(
|
|
148
|
+
* "/admin/posts/:id",
|
|
149
|
+
* { id: postId }
|
|
150
|
+
* );
|
|
151
|
+
*
|
|
152
|
+
* useEffect(() => {
|
|
153
|
+
* fetcher.load();
|
|
154
|
+
* }, [fetcher.load]);
|
|
155
|
+
*
|
|
156
|
+
* if (fetcher.state === "loading" && !fetcher.data) {
|
|
157
|
+
* return <div>Loading...</div>;
|
|
158
|
+
* }
|
|
159
|
+
*
|
|
160
|
+
* const post = fetcher.data?.post;
|
|
161
|
+
*
|
|
162
|
+
* return (
|
|
163
|
+
* <submitter.Form method="PUT">
|
|
164
|
+
* <input name="title" defaultValue={post?.title} />
|
|
165
|
+
* <textarea name="content" defaultValue={post?.content} />
|
|
166
|
+
* <label>
|
|
167
|
+
* <input name="published" type="checkbox" defaultChecked={post?.published} />
|
|
168
|
+
* Published
|
|
169
|
+
* </label>
|
|
170
|
+
* <button disabled={submitter.state !== "idle"}>
|
|
171
|
+
* {submitter.state === "submitting" ? "Saving..." : "Save"}
|
|
172
|
+
* </button>
|
|
173
|
+
* </submitter.Form>
|
|
174
|
+
* );
|
|
175
|
+
* }
|
|
176
|
+
* ```
|
|
27
177
|
*/
|
|
28
178
|
|
|
29
179
|
import { fail, type MaybeError } from "@firtoz/maybe-error";
|
package/src/types/HrefArgs.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
import type { href } from "react-router";
|
|
2
1
|
import type { RegisterPages } from "./RegisterPages";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
> extends
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
// Helper types matching React Router's internal implementation
|
|
4
|
+
type Equal<X, Y> =
|
|
5
|
+
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
|
|
6
|
+
? true
|
|
7
|
+
: false;
|
|
8
|
+
|
|
9
|
+
type ToArgs<Params extends Record<string, string | undefined>> =
|
|
10
|
+
Equal<
|
|
11
|
+
Params,
|
|
12
|
+
// biome-ignore lint/complexity/noBannedTypes: This is intentionally empty.
|
|
13
|
+
{}
|
|
14
|
+
> extends true
|
|
15
|
+
? []
|
|
16
|
+
: Partial<Params> extends Params
|
|
17
|
+
? [Params] | []
|
|
18
|
+
: [Params];
|
|
19
|
+
|
|
20
|
+
// Matches React Router's Args type structure
|
|
21
|
+
export type HrefArgs<T extends keyof RegisterPages> = ToArgs<
|
|
22
|
+
RegisterPages[T]["params"]
|
|
23
|
+
>;
|
package/src/useCachedFetch.ts
CHANGED
|
@@ -19,8 +19,8 @@ export const useCachedFetch = <TInfo extends RouteWithLoaderModule>(
|
|
|
19
19
|
} => {
|
|
20
20
|
// Generate URL using href, same as useDynamicFetcher
|
|
21
21
|
const url = useMemo(() => {
|
|
22
|
-
// biome-ignore lint/suspicious/noExplicitAny:
|
|
23
|
-
return
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: Intentional
|
|
23
|
+
return href(path, ...(args as any));
|
|
24
24
|
}, [path, args]);
|
|
25
25
|
|
|
26
26
|
// Use the generated URL as the cache key
|
package/src/useDynamicFetcher.ts
CHANGED
|
@@ -1,19 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Type-safe dynamic data fetching hook for React Router 7
|
|
3
|
+
*
|
|
4
|
+
* This module provides a hook that creates a type-safe fetcher for loading data
|
|
5
|
+
* from dynamic routes with full TypeScript inference for the loader response and route params.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ### Route Setup (`app/routes/api.users.$userId.ts`)
|
|
9
|
+
*
|
|
10
|
+
* First, set up your route with the required exports:
|
|
11
|
+
*
|
|
12
|
+
* ```typescript
|
|
13
|
+
* import type { RoutePath } from "@firtoz/router-toolkit";
|
|
14
|
+
*
|
|
15
|
+
* // Export the route path for type inference
|
|
16
|
+
* export const route: RoutePath<"/api/users/:userId"> = "/api/users/:userId";
|
|
17
|
+
*
|
|
18
|
+
* // Define the loader with a typed return value
|
|
19
|
+
* export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|
20
|
+
* const user = await db.users.findUnique({ where: { id: params.userId } });
|
|
21
|
+
* return {
|
|
22
|
+
* user: {
|
|
23
|
+
* id: user.id,
|
|
24
|
+
* email: user.email,
|
|
25
|
+
* displayName: user.displayName,
|
|
26
|
+
* createdAt: user.createdAt.toISOString(),
|
|
27
|
+
* },
|
|
28
|
+
* };
|
|
29
|
+
* };
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ### Using the hook in a component
|
|
34
|
+
*
|
|
35
|
+
* ```tsx
|
|
36
|
+
* import { useDynamicFetcher } from "@firtoz/router-toolkit";
|
|
37
|
+
* import { useEffect } from "react";
|
|
38
|
+
*
|
|
39
|
+
* function UserProfile({ userId }: { userId: string }) {
|
|
40
|
+
* // Type-safe fetcher with full inference
|
|
41
|
+
* const fetcher = useDynamicFetcher<typeof import("./api.users.$userId")>(
|
|
42
|
+
* "/api/users/:userId",
|
|
43
|
+
* { userId }
|
|
44
|
+
* );
|
|
45
|
+
*
|
|
46
|
+
* // Load data on mount
|
|
47
|
+
* useEffect(() => {
|
|
48
|
+
* fetcher.load();
|
|
49
|
+
* }, [fetcher.load]);
|
|
50
|
+
*
|
|
51
|
+
* // fetcher.data is fully typed: { user: { id, email, displayName, createdAt } } | undefined
|
|
52
|
+
* if (fetcher.state === "loading") {
|
|
53
|
+
* return <div>Loading...</div>;
|
|
54
|
+
* }
|
|
55
|
+
*
|
|
56
|
+
* if (!fetcher.data) {
|
|
57
|
+
* return <div>No user found</div>;
|
|
58
|
+
* }
|
|
59
|
+
*
|
|
60
|
+
* return (
|
|
61
|
+
* <div>
|
|
62
|
+
* <h1>{fetcher.data.user.displayName}</h1>
|
|
63
|
+
* <p>{fetcher.data.user.email}</p>
|
|
64
|
+
* </div>
|
|
65
|
+
* );
|
|
66
|
+
* }
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ### Loading with query parameters
|
|
71
|
+
*
|
|
72
|
+
* ```tsx
|
|
73
|
+
* function SearchResults() {
|
|
74
|
+
* const fetcher = useDynamicFetcher<typeof import("./api.search")>("/api/search");
|
|
75
|
+
*
|
|
76
|
+
* const handleSearch = (query: string, page: number) => {
|
|
77
|
+
* // Pass query params to the load function
|
|
78
|
+
* fetcher.load({ q: query, page: String(page) });
|
|
79
|
+
* };
|
|
80
|
+
*
|
|
81
|
+
* return (
|
|
82
|
+
* <div>
|
|
83
|
+
* <input onChange={(e) => handleSearch(e.target.value, 1)} />
|
|
84
|
+
* {fetcher.data?.results.map((result) => (
|
|
85
|
+
* <div key={result.id}>{result.title}</div>
|
|
86
|
+
* ))}
|
|
87
|
+
* </div>
|
|
88
|
+
* );
|
|
89
|
+
* }
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ### Combining with useDynamicSubmitter for full CRUD
|
|
94
|
+
*
|
|
95
|
+
* You can use `useDynamicFetcher` alongside `useDynamicSubmitter` to create
|
|
96
|
+
* complete CRUD interfaces with type safety:
|
|
97
|
+
*
|
|
98
|
+
* ```tsx
|
|
99
|
+
* import { useDynamicFetcher, useDynamicSubmitter } from "@firtoz/router-toolkit";
|
|
100
|
+
*
|
|
101
|
+
* function PostEditor({ postId }: { postId: string }) {
|
|
102
|
+
* // Fetch post data
|
|
103
|
+
* const fetcher = useDynamicFetcher<typeof import("./api.posts.$postId")>(
|
|
104
|
+
* "/api/posts/:postId",
|
|
105
|
+
* { postId }
|
|
106
|
+
* );
|
|
107
|
+
*
|
|
108
|
+
* // Submit updates
|
|
109
|
+
* const submitter = useDynamicSubmitter<typeof import("./api.posts.$postId")>(
|
|
110
|
+
* "/api/posts/:postId",
|
|
111
|
+
* { postId }
|
|
112
|
+
* );
|
|
113
|
+
*
|
|
114
|
+
* useEffect(() => {
|
|
115
|
+
* fetcher.load();
|
|
116
|
+
* }, [fetcher.load]);
|
|
117
|
+
*
|
|
118
|
+
* const handleSave = async (title: string, content: string) => {
|
|
119
|
+
* await submitter.submitJson({ title, content }, { method: "PUT" });
|
|
120
|
+
* // Reload after save
|
|
121
|
+
* fetcher.load();
|
|
122
|
+
* };
|
|
123
|
+
*
|
|
124
|
+
* if (!fetcher.data) return <div>Loading...</div>;
|
|
125
|
+
*
|
|
126
|
+
* return (
|
|
127
|
+
* <form onSubmit={(e) => {
|
|
128
|
+
* e.preventDefault();
|
|
129
|
+
* const form = new FormData(e.currentTarget);
|
|
130
|
+
* handleSave(form.get("title") as string, form.get("content") as string);
|
|
131
|
+
* }}>
|
|
132
|
+
* <input name="title" defaultValue={fetcher.data.post.title} />
|
|
133
|
+
* <textarea name="content" defaultValue={fetcher.data.post.content} />
|
|
134
|
+
* <button disabled={submitter.state !== "idle"}>
|
|
135
|
+
* {submitter.state === "submitting" ? "Saving..." : "Save"}
|
|
136
|
+
* </button>
|
|
137
|
+
* </form>
|
|
138
|
+
* );
|
|
139
|
+
* }
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
|
|
1
143
|
import { useCallback, useMemo } from "react";
|
|
2
144
|
import { href, useFetcher } from "react-router";
|
|
3
145
|
import type { HrefArgs } from "./types/HrefArgs";
|
|
4
146
|
import type { RouteWithLoaderModule } from "./types/RouteWithLoaderModule";
|
|
5
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Creates a type-safe fetcher for loading data from dynamic routes.
|
|
150
|
+
*
|
|
151
|
+
* This hook provides full TypeScript inference for:
|
|
152
|
+
* - Route parameters (from the route path)
|
|
153
|
+
* - Loader response type (from the route's loader export)
|
|
154
|
+
*
|
|
155
|
+
* @template TInfo - The route module type (use `typeof import("./route-file")`)
|
|
156
|
+
*
|
|
157
|
+
* @param path - The route path (must match the route's `route` export)
|
|
158
|
+
* @param args - Route parameters (if the route has dynamic segments like `:id`)
|
|
159
|
+
*
|
|
160
|
+
* @returns An extended fetcher object with:
|
|
161
|
+
* - `load` - Function to load data, optionally with query parameters
|
|
162
|
+
* - `data` - Response data from the loader (typed)
|
|
163
|
+
* - `state` - Fetcher state ("idle" | "loading" | "submitting")
|
|
164
|
+
* - All other useFetcher properties (except `submit`)
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ### Basic usage
|
|
168
|
+
*
|
|
169
|
+
* ```typescript
|
|
170
|
+
* // In your route file (app/routes/api.products.$productId.ts):
|
|
171
|
+
* export const route: RoutePath<"/api/products/:productId"> = "/api/products/:productId";
|
|
172
|
+
* export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|
173
|
+
* return { product: await getProduct(params.productId) };
|
|
174
|
+
* };
|
|
175
|
+
*
|
|
176
|
+
* // In your component:
|
|
177
|
+
* const fetcher = useDynamicFetcher<typeof import("./api.products.$productId")>(
|
|
178
|
+
* "/api/products/:productId",
|
|
179
|
+
* { productId: "abc123" }
|
|
180
|
+
* );
|
|
181
|
+
*
|
|
182
|
+
* useEffect(() => {
|
|
183
|
+
* fetcher.load();
|
|
184
|
+
* }, [fetcher.load]);
|
|
185
|
+
*
|
|
186
|
+
* // fetcher.data is typed as { product: Product } | undefined
|
|
187
|
+
* ```
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ### With query parameters
|
|
191
|
+
*
|
|
192
|
+
* ```typescript
|
|
193
|
+
* const fetcher = useDynamicFetcher<typeof import("./api.search")>("/api/search");
|
|
194
|
+
*
|
|
195
|
+
* // Load with query params: /api/search?q=hello&limit=10
|
|
196
|
+
* fetcher.load({ q: "hello", limit: "10" });
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
6
199
|
export const useDynamicFetcher = <TInfo extends RouteWithLoaderModule>(
|
|
7
200
|
path: TInfo["route"],
|
|
8
201
|
...args: TInfo["route"] extends "undefined"
|
|
9
202
|
? HrefArgs<"/">
|
|
10
203
|
: HrefArgs<TInfo["route"]>
|
|
11
204
|
): Omit<ReturnType<typeof useFetcher<TInfo["loader"]>>, "load" | "submit"> & {
|
|
205
|
+
/**
|
|
206
|
+
* Load data from the route's loader.
|
|
207
|
+
*
|
|
208
|
+
* @param queryParams - Optional query parameters to append to the URL
|
|
209
|
+
* @returns A promise that resolves when the load is complete
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```typescript
|
|
213
|
+
* // Load without query params
|
|
214
|
+
* fetcher.load();
|
|
215
|
+
*
|
|
216
|
+
* // Load with query params
|
|
217
|
+
* fetcher.load({ page: "2", sort: "name" });
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
12
220
|
load: (queryParams?: Record<string, string>) => Promise<void>;
|
|
13
221
|
} => {
|
|
14
222
|
const url = useMemo(() => {
|
|
15
|
-
// biome-ignore lint/suspicious/noExplicitAny:
|
|
16
|
-
return
|
|
223
|
+
// biome-ignore lint/suspicious/noExplicitAny: Intentional
|
|
224
|
+
return href(path, ...(args as any));
|
|
17
225
|
}, [path, args]);
|
|
18
226
|
|
|
19
227
|
const fetcher = useFetcher<TInfo["loader"]>({
|
|
@@ -1,3 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Type-safe dynamic form submission hook for React Router 7
|
|
3
|
+
*
|
|
4
|
+
* This module provides a hook that creates a type-safe fetcher for submitting forms
|
|
5
|
+
* to dynamic routes with full TypeScript inference for the form schema and route params.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ### Route Setup (`app/routes/admin.posts.$id.tsx`)
|
|
9
|
+
*
|
|
10
|
+
* First, set up your route with the required exports:
|
|
11
|
+
*
|
|
12
|
+
* ```typescript
|
|
13
|
+
* import { z } from "zod";
|
|
14
|
+
* import { formAction, type RoutePath } from "@firtoz/router-toolkit";
|
|
15
|
+
* import { success, fail } from "@firtoz/maybe-error";
|
|
16
|
+
*
|
|
17
|
+
* // Export the route path for type inference
|
|
18
|
+
* export const route: RoutePath<"/admin/posts/:id"> = "/admin/posts/:id";
|
|
19
|
+
*
|
|
20
|
+
* // Define the form schema
|
|
21
|
+
* export const formSchema = z.object({
|
|
22
|
+
* title: z.string().min(1, "Title is required"),
|
|
23
|
+
* content: z.string().min(10, "Content must be at least 10 characters"),
|
|
24
|
+
* published: z.boolean().optional().default(false),
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* // Create the action using formAction
|
|
28
|
+
* export const action = formAction({
|
|
29
|
+
* schema: formSchema,
|
|
30
|
+
* handler: async ({ request, params }, formData) => {
|
|
31
|
+
* const postId = params.id;
|
|
32
|
+
* const updated = await db.posts.update({
|
|
33
|
+
* where: { id: postId },
|
|
34
|
+
* data: formData,
|
|
35
|
+
* });
|
|
36
|
+
* return success(updated);
|
|
37
|
+
* },
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ### Using the hook in a component
|
|
43
|
+
*
|
|
44
|
+
* ```tsx
|
|
45
|
+
* import { useDynamicSubmitter } from "@firtoz/router-toolkit";
|
|
46
|
+
*
|
|
47
|
+
* function EditPostForm({ postId }: { postId: string }) {
|
|
48
|
+
* // Type-safe submitter with full inference
|
|
49
|
+
* const submitter = useDynamicSubmitter<typeof import("./admin.posts.$id")>(
|
|
50
|
+
* "/admin/posts/:id",
|
|
51
|
+
* { id: postId }
|
|
52
|
+
* );
|
|
53
|
+
*
|
|
54
|
+
* // submitter.data is the typed response from the action
|
|
55
|
+
* // submitter.state is "idle" | "loading" | "submitting"
|
|
56
|
+
*
|
|
57
|
+
* // Option 1: Submit as JSON (recommended for programmatic submissions)
|
|
58
|
+
* const handleSubmitJson = async () => {
|
|
59
|
+
* await submitter.submitJson(
|
|
60
|
+
* { title: "My Post", content: "Post content here", published: true },
|
|
61
|
+
* { method: "POST" }
|
|
62
|
+
* );
|
|
63
|
+
* };
|
|
64
|
+
*
|
|
65
|
+
* // Option 2: Submit with FormData or SubmitTarget
|
|
66
|
+
* const handleSubmit = async (formData: FormData) => {
|
|
67
|
+
* await submitter.submit(formData, { method: "POST" });
|
|
68
|
+
* };
|
|
69
|
+
*
|
|
70
|
+
* // Option 3: Use the Form component
|
|
71
|
+
* return (
|
|
72
|
+
* <submitter.Form method="POST">
|
|
73
|
+
* <input name="title" />
|
|
74
|
+
* <textarea name="content" />
|
|
75
|
+
* <button type="submit">Save</button>
|
|
76
|
+
* </submitter.Form>
|
|
77
|
+
* );
|
|
78
|
+
* }
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ### Handling responses
|
|
83
|
+
*
|
|
84
|
+
* ```tsx
|
|
85
|
+
* function LoginForm() {
|
|
86
|
+
* const submitter = useDynamicSubmitter<typeof import("./auth.login")>("/auth/login");
|
|
87
|
+
*
|
|
88
|
+
* useEffect(() => {
|
|
89
|
+
* if (submitter.data?.success) {
|
|
90
|
+
* // Handle success
|
|
91
|
+
* console.log("Logged in as:", submitter.data.value.user.email);
|
|
92
|
+
* } else if (submitter.data && !submitter.data.success) {
|
|
93
|
+
* // Handle error
|
|
94
|
+
* if (submitter.data.error.type === "validation") {
|
|
95
|
+
* console.log("Validation errors:", submitter.data.error.error);
|
|
96
|
+
* }
|
|
97
|
+
* }
|
|
98
|
+
* }, [submitter.data]);
|
|
99
|
+
*
|
|
100
|
+
* return (
|
|
101
|
+
* <submitter.Form method="POST">
|
|
102
|
+
* <input name="email" type="email" />
|
|
103
|
+
* <input name="password" type="password" />
|
|
104
|
+
* <button disabled={submitter.state !== "idle"}>
|
|
105
|
+
* {submitter.state === "submitting" ? "Logging in..." : "Login"}
|
|
106
|
+
* </button>
|
|
107
|
+
* </submitter.Form>
|
|
108
|
+
* );
|
|
109
|
+
* }
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
|
|
1
113
|
// biome-ignore lint/style/useImportType: We need to import React here.
|
|
2
114
|
import React, { useCallback, useMemo } from "react";
|
|
3
115
|
import {
|
|
@@ -12,12 +124,35 @@ import type { Func } from "./types/Func";
|
|
|
12
124
|
import type { HrefArgs } from "./types/HrefArgs";
|
|
13
125
|
import type { RegisterPages } from "./types/RegisterPages";
|
|
14
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Represents a route module with the required exports for useDynamicSubmitter.
|
|
129
|
+
*
|
|
130
|
+
* A valid route module must export:
|
|
131
|
+
* - `route`: The route path (e.g., "/admin/posts/:id")
|
|
132
|
+
* - `action`: The form action handler created with `formAction`
|
|
133
|
+
* - `formSchema`: The Zod schema for form validation
|
|
134
|
+
*/
|
|
15
135
|
type RouteModule = {
|
|
16
136
|
route: keyof RegisterPages;
|
|
17
137
|
action: Func;
|
|
18
138
|
formSchema: z.ZodType;
|
|
19
139
|
};
|
|
20
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Function type for submitting form data with a SubmitTarget.
|
|
143
|
+
*
|
|
144
|
+
* Accepts the form schema data combined with SubmitTarget (FormData, HTMLFormElement, etc.)
|
|
145
|
+
* Use this when you have a FormData object or form element reference.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```typescript
|
|
149
|
+
* // With FormData
|
|
150
|
+
* submitter.submit(formData, { method: "POST" });
|
|
151
|
+
*
|
|
152
|
+
* // With form element reference
|
|
153
|
+
* submitter.submit(formRef.current, { method: "POST" });
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
21
156
|
type SubmitFunc<TModule extends RouteModule> = (
|
|
22
157
|
target: z.infer<TModule["formSchema"]> & SubmitTarget,
|
|
23
158
|
options: Omit<SubmitOptions, "action" | "method" | "encType"> & {
|
|
@@ -25,6 +160,42 @@ type SubmitFunc<TModule extends RouteModule> = (
|
|
|
25
160
|
},
|
|
26
161
|
) => Promise<void>;
|
|
27
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Function type for submitting form data as JSON.
|
|
165
|
+
*
|
|
166
|
+
* Accepts only the inferred form schema type (plain object).
|
|
167
|
+
* Automatically serializes the data as JSON. This is the recommended
|
|
168
|
+
* approach for programmatic form submissions.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```typescript
|
|
172
|
+
* // Submit a plain object - fully type-safe
|
|
173
|
+
* await submitter.submitJson(
|
|
174
|
+
* { email: "user@example.com", password: "secret123", rememberMe: true },
|
|
175
|
+
* { method: "POST" }
|
|
176
|
+
* );
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
type SubmitJsonFunc<TModule extends RouteModule> = (
|
|
180
|
+
data: z.infer<TModule["formSchema"]>,
|
|
181
|
+
options: Omit<SubmitOptions, "action" | "method" | "encType"> & {
|
|
182
|
+
method: Exclude<SubmitOptions["method"], "GET">;
|
|
183
|
+
},
|
|
184
|
+
) => Promise<void>;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Form component type with pre-bound action URL.
|
|
188
|
+
*
|
|
189
|
+
* Renders a form element that automatically submits to the correct route.
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```typescript
|
|
193
|
+
* <submitter.Form method="POST">
|
|
194
|
+
* <input name="title" />
|
|
195
|
+
* <button type="submit">Submit</button>
|
|
196
|
+
* </submitter.Form>
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
28
199
|
type SubmitForm = (
|
|
29
200
|
props: Omit<
|
|
30
201
|
FetcherFormProps & React.RefAttributes<HTMLFormElement>,
|
|
@@ -34,6 +205,59 @@ type SubmitForm = (
|
|
|
34
205
|
},
|
|
35
206
|
) => React.ReactElement;
|
|
36
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Creates a type-safe fetcher for submitting forms to dynamic routes.
|
|
210
|
+
*
|
|
211
|
+
* This hook provides full TypeScript inference for:
|
|
212
|
+
* - Route parameters (from the route path)
|
|
213
|
+
* - Form data schema (from the route's formSchema export)
|
|
214
|
+
* - Action response type (from the route's action export)
|
|
215
|
+
*
|
|
216
|
+
* @template TInfo - The route module type (use `typeof import("./route-file")`)
|
|
217
|
+
*
|
|
218
|
+
* @param path - The route path (must match the route's `route` export)
|
|
219
|
+
* @param args - Route parameters (if the route has dynamic segments like `:id`)
|
|
220
|
+
*
|
|
221
|
+
* @returns An extended fetcher object with:
|
|
222
|
+
* - `submit` - Submit with FormData/SubmitTarget (includes schema type)
|
|
223
|
+
* - `submitJson` - Submit a plain object as JSON (schema type only)
|
|
224
|
+
* - `Form` - Pre-bound form component
|
|
225
|
+
* - `data` - Response data from the action (typed)
|
|
226
|
+
* - `state` - Fetcher state ("idle" | "loading" | "submitting")
|
|
227
|
+
* - All other useFetcher properties
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ### Basic usage with route parameters
|
|
231
|
+
*
|
|
232
|
+
* ```typescript
|
|
233
|
+
* // In your route file (app/routes/users.$userId.settings.tsx):
|
|
234
|
+
* export const route: RoutePath<"/users/:userId/settings"> = "/users/:userId/settings";
|
|
235
|
+
* export const formSchema = z.object({
|
|
236
|
+
* displayName: z.string().min(2),
|
|
237
|
+
* email: z.string().email(),
|
|
238
|
+
* notifications: z.boolean().default(true),
|
|
239
|
+
* });
|
|
240
|
+
* export const action = formAction({ schema: formSchema, handler: ... });
|
|
241
|
+
*
|
|
242
|
+
* // In your component:
|
|
243
|
+
* const submitter = useDynamicSubmitter<typeof import("./users.$userId.settings")>(
|
|
244
|
+
* "/users/:userId/settings",
|
|
245
|
+
* { userId: "123" }
|
|
246
|
+
* );
|
|
247
|
+
*
|
|
248
|
+
* // Submit using submitJson (type-safe, no FormData needed)
|
|
249
|
+
* await submitter.submitJson({
|
|
250
|
+
* displayName: "John Doe",
|
|
251
|
+
* email: "john@example.com",
|
|
252
|
+
* notifications: true,
|
|
253
|
+
* }, { method: "POST" });
|
|
254
|
+
*
|
|
255
|
+
* // Check the response
|
|
256
|
+
* if (submitter.data?.success) {
|
|
257
|
+
* console.log("Settings updated!");
|
|
258
|
+
* }
|
|
259
|
+
* ```
|
|
260
|
+
*/
|
|
37
261
|
export const useDynamicSubmitter = <TInfo extends RouteModule>(
|
|
38
262
|
path: TInfo["route"],
|
|
39
263
|
...args: TInfo["route"] extends "undefined"
|
|
@@ -43,12 +267,16 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
|
|
|
43
267
|
ReturnType<typeof useFetcher<TInfo["action"]>>,
|
|
44
268
|
"load" | "submit" | "Form"
|
|
45
269
|
> & {
|
|
270
|
+
/** Submit with FormData or SubmitTarget (schema type & SubmitTarget) */
|
|
46
271
|
submit: SubmitFunc<TInfo>;
|
|
272
|
+
/** Submit a plain object as JSON (schema type only, recommended for programmatic use) */
|
|
273
|
+
submitJson: SubmitJsonFunc<TInfo>;
|
|
274
|
+
/** Pre-bound Form component with action URL already set */
|
|
47
275
|
Form: SubmitForm;
|
|
48
276
|
} => {
|
|
49
277
|
const url = useMemo(() => {
|
|
50
|
-
// biome-ignore lint/suspicious/noExplicitAny:
|
|
51
|
-
return
|
|
278
|
+
// biome-ignore lint/suspicious/noExplicitAny: Intentional
|
|
279
|
+
return href(path, ...(args as any));
|
|
52
280
|
}, [path, args]);
|
|
53
281
|
|
|
54
282
|
const fetcher = useFetcher<TInfo["action"]>({
|
|
@@ -57,7 +285,6 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
|
|
|
57
285
|
|
|
58
286
|
const submit: SubmitFunc<TInfo> = useCallback(
|
|
59
287
|
(target, options) => {
|
|
60
|
-
// console.log("Submitting form to", url, target, options);
|
|
61
288
|
return fetcher.submit(target, {
|
|
62
289
|
...options,
|
|
63
290
|
action: url,
|
|
@@ -67,6 +294,17 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
|
|
|
67
294
|
[fetcher.submit, url],
|
|
68
295
|
);
|
|
69
296
|
|
|
297
|
+
const submitJson: SubmitJsonFunc<TInfo> = useCallback(
|
|
298
|
+
(data, options) => {
|
|
299
|
+
return fetcher.submit(data as SubmitTarget, {
|
|
300
|
+
...options,
|
|
301
|
+
action: url,
|
|
302
|
+
encType: "application/json",
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
[fetcher.submit, url],
|
|
306
|
+
);
|
|
307
|
+
|
|
70
308
|
const OriginalForm = fetcher.Form;
|
|
71
309
|
|
|
72
310
|
const Form: SubmitForm = useCallback(
|
|
@@ -79,6 +317,7 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
|
|
|
79
317
|
return {
|
|
80
318
|
...fetcher,
|
|
81
319
|
submit,
|
|
320
|
+
submitJson,
|
|
82
321
|
Form,
|
|
83
322
|
};
|
|
84
323
|
};
|