@firtoz/router-toolkit 5.0.1 → 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 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.string().email(),
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.0.1",
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",
@@ -56,9 +56,9 @@
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@firtoz/maybe-error": "^1.5.1",
59
- "react": "^19.2.0",
60
- "react-router": "^7.9.4",
61
- "zod": "^4.1.12"
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.0",
73
+ "@testing-library/react": "^16.3.1",
74
74
  "@types/jsdom": "^27.0.0",
75
- "@types/react": "^19.2.2",
76
- "bun-types": "^1.3.0",
77
- "jsdom": "^27.0.1"
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
- * const schema = z.object({
14
- * email: z.string().email(),
15
- * password: z.string().min(8),
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 (args, data) => {
21
- * // data is fully typed based on the schema
22
- * const user = await authenticateUser(data.email, data.password);
23
- * return success(user);
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";
@@ -1,8 +1,23 @@
1
- import type { href } from "react-router";
2
1
  import type { RegisterPages } from "./RegisterPages";
3
2
 
4
- export type HrefArgs<T extends keyof RegisterPages> = Parameters<
5
- typeof href<T>
6
- > extends [string, ...infer Rest]
7
- ? Rest
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
+ >;
@@ -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: Typechecks complain about this so we need to cast to any
23
- return (href as any)(path, ...args);
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
@@ -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: Typechecks complain about this so we need to cast to any
16
- return (href as any)(path, ...args);
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: Typechecks complain about this so we need to cast to any
51
- return (href as any)(path, ...args);
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
  };