@firtoz/router-toolkit 5.0.1 → 5.2.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.2.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,177 @@
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 (defaults to POST)
60
+ * const handleLoginJson = async () => {
61
+ * await submitter.submitJson({
62
+ * email: "user@example.com",
63
+ * password: "secret123",
64
+ * rememberMe: true,
65
+ * });
66
+ * };
67
+ *
68
+ * // Option 2: Use the Form component (defaults to POST)
69
+ * return (
70
+ * <submitter.Form>
71
+ * <input name="email" type="email" placeholder="Email" />
72
+ * <input name="password" type="password" placeholder="Password" />
73
+ * <label>
74
+ * <input name="rememberMe" type="checkbox" /> Remember me
75
+ * </label>
76
+ * <button disabled={submitter.state !== "idle"}>
77
+ * {submitter.state === "submitting" ? "Logging in..." : "Login"}
78
+ * </button>
79
+ *
80
+ * {submitter.data && !submitter.data.success && (
81
+ * <div className="error">
82
+ * {submitter.data.error.type === "validation"
83
+ * ? "Please check your inputs"
84
+ * : submitter.data.error.type === "handler"
85
+ * ? submitter.data.error.error // "Invalid email or password"
86
+ * : "An unexpected error occurred"}
87
+ * </div>
88
+ * )}
89
+ * </submitter.Form>
90
+ * );
91
+ * }
92
+ * ```
93
+ *
94
+ * @example
95
+ * ### Combined loader + action route (`app/routes/admin.posts.$id.tsx`)
96
+ *
97
+ * You can combine `formAction` with a loader for full CRUD operations:
98
+ *
99
+ * ```typescript
100
+ * import { z } from "zod";
101
+ * import { formAction, type RoutePath } from "@firtoz/router-toolkit";
102
+ * import { success, fail } from "@firtoz/maybe-error";
103
+ * import type { LoaderFunctionArgs } from "react-router";
104
+ *
105
+ * export const route: RoutePath<"/admin/posts/:id"> = "/admin/posts/:id";
106
+ *
107
+ * // Loader for fetching data (used with useDynamicFetcher)
108
+ * export const loader = async ({ params }: LoaderFunctionArgs) => {
109
+ * const post = await db.posts.findUnique({ where: { id: params.id } });
110
+ * return { post };
111
+ * };
112
+ *
113
+ * // Form schema for updates
114
+ * export const formSchema = z.object({
115
+ * title: z.string().min(1, "Title is required"),
116
+ * content: z.string().min(10, "Content must be at least 10 characters"),
117
+ * published: z.boolean().optional().default(false),
118
+ * });
119
+ *
120
+ * // Action for handling form submissions (used with useDynamicSubmitter)
121
+ * export const action = formAction({
122
+ * schema: formSchema,
123
+ * handler: async ({ params }, data) => {
124
+ * const updated = await db.posts.update({
125
+ * where: { id: params.id },
126
+ * data,
127
+ * });
128
+ * return success({ post: updated });
24
129
  * },
25
130
  * });
26
131
  * ```
132
+ *
133
+ * @example
134
+ * ### Full CRUD component using both hooks
135
+ *
136
+ * ```tsx
137
+ * import { useDynamicFetcher, useDynamicSubmitter } from "@firtoz/router-toolkit";
138
+ * import { useEffect } from "react";
139
+ *
140
+ * function PostEditor({ postId }: { postId: string }) {
141
+ * // Fetch post data
142
+ * const fetcher = useDynamicFetcher<typeof import("./admin.posts.$id")>(
143
+ * "/admin/posts/:id",
144
+ * { id: postId }
145
+ * );
146
+ *
147
+ * // Submit updates
148
+ * const submitter = useDynamicSubmitter<typeof import("./admin.posts.$id")>(
149
+ * "/admin/posts/:id",
150
+ * { id: postId }
151
+ * );
152
+ *
153
+ * useEffect(() => {
154
+ * fetcher.load();
155
+ * }, [fetcher.load]);
156
+ *
157
+ * if (fetcher.state === "loading" && !fetcher.data) {
158
+ * return <div>Loading...</div>;
159
+ * }
160
+ *
161
+ * const post = fetcher.data?.post;
162
+ *
163
+ * return (
164
+ * <submitter.Form method="PUT">
165
+ * <input name="title" defaultValue={post?.title} />
166
+ * <textarea name="content" defaultValue={post?.content} />
167
+ * <label>
168
+ * <input name="published" type="checkbox" defaultChecked={post?.published} />
169
+ * Published
170
+ * </label>
171
+ * <button disabled={submitter.state !== "idle"}>
172
+ * {submitter.state === "submitting" ? "Saving..." : "Save"}
173
+ * </button>
174
+ * </submitter.Form>
175
+ * );
176
+ * }
177
+ * ```
27
178
  */
28
179
 
29
180
  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,117 @@
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
+ * // Defaults to POST if no options provided
59
+ * const handleSubmitJson = async () => {
60
+ * await submitter.submitJson({
61
+ * title: "My Post",
62
+ * content: "Post content here",
63
+ * published: true,
64
+ * });
65
+ * };
66
+ *
67
+ * // Option 2: Submit with FormData or SubmitTarget
68
+ * const handleSubmit = async (formData: FormData) => {
69
+ * await submitter.submit(formData, { method: "POST" });
70
+ * };
71
+ *
72
+ * // Option 3: Use the Form component (defaults to POST)
73
+ * return (
74
+ * <submitter.Form>
75
+ * <input name="title" />
76
+ * <textarea name="content" />
77
+ * <button type="submit">Save</button>
78
+ * </submitter.Form>
79
+ * );
80
+ * }
81
+ * ```
82
+ *
83
+ * @example
84
+ * ### Handling responses
85
+ *
86
+ * ```tsx
87
+ * function LoginForm() {
88
+ * const submitter = useDynamicSubmitter<typeof import("./auth.login")>("/auth/login");
89
+ *
90
+ * useEffect(() => {
91
+ * if (submitter.data?.success) {
92
+ * // Handle success
93
+ * console.log("Logged in as:", submitter.data.value.user.email);
94
+ * } else if (submitter.data && !submitter.data.success) {
95
+ * // Handle error
96
+ * if (submitter.data.error.type === "validation") {
97
+ * console.log("Validation errors:", submitter.data.error.error);
98
+ * }
99
+ * }
100
+ * }, [submitter.data]);
101
+ *
102
+ * return (
103
+ * <submitter.Form>
104
+ * <input name="email" type="email" />
105
+ * <input name="password" type="password" />
106
+ * <button disabled={submitter.state !== "idle"}>
107
+ * {submitter.state === "submitting" ? "Logging in..." : "Login"}
108
+ * </button>
109
+ * </submitter.Form>
110
+ * );
111
+ * }
112
+ * ```
113
+ */
114
+
1
115
  // biome-ignore lint/style/useImportType: We need to import React here.
2
116
  import React, { useCallback, useMemo } from "react";
3
117
  import {
@@ -12,12 +126,35 @@ import type { Func } from "./types/Func";
12
126
  import type { HrefArgs } from "./types/HrefArgs";
13
127
  import type { RegisterPages } from "./types/RegisterPages";
14
128
 
129
+ /**
130
+ * Represents a route module with the required exports for useDynamicSubmitter.
131
+ *
132
+ * A valid route module must export:
133
+ * - `route`: The route path (e.g., "/admin/posts/:id")
134
+ * - `action`: The form action handler created with `formAction`
135
+ * - `formSchema`: The Zod schema for form validation
136
+ */
15
137
  type RouteModule = {
16
138
  route: keyof RegisterPages;
17
139
  action: Func;
18
140
  formSchema: z.ZodType;
19
141
  };
20
142
 
143
+ /**
144
+ * Function type for submitting form data with a SubmitTarget.
145
+ *
146
+ * Accepts the form schema data combined with SubmitTarget (FormData, HTMLFormElement, etc.)
147
+ * Use this when you have a FormData object or form element reference.
148
+ *
149
+ * @example
150
+ * ```typescript
151
+ * // With FormData
152
+ * submitter.submit(formData, { method: "POST" });
153
+ *
154
+ * // With form element reference
155
+ * submitter.submit(formRef.current, { method: "POST" });
156
+ * ```
157
+ */
21
158
  type SubmitFunc<TModule extends RouteModule> = (
22
159
  target: z.infer<TModule["formSchema"]> & SubmitTarget,
23
160
  options: Omit<SubmitOptions, "action" | "method" | "encType"> & {
@@ -25,15 +162,126 @@ type SubmitFunc<TModule extends RouteModule> = (
25
162
  },
26
163
  ) => Promise<void>;
27
164
 
165
+ /**
166
+ * Options for submitJson function.
167
+ * Method defaults to "POST" if not specified.
168
+ */
169
+ type SubmitJsonOptions = Omit<
170
+ SubmitOptions,
171
+ "action" | "method" | "encType"
172
+ > & {
173
+ method?: Exclude<SubmitOptions["method"], "GET">;
174
+ };
175
+
176
+ /**
177
+ * Function type for submitting form data as JSON.
178
+ *
179
+ * Accepts only the inferred form schema type (plain object).
180
+ * Automatically serializes the data as JSON. This is the recommended
181
+ * approach for programmatic form submissions.
182
+ *
183
+ * Options are optional and default to `{ method: "POST" }`.
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * // Submit a plain object - fully type-safe (defaults to POST)
188
+ * await submitter.submitJson({
189
+ * email: "user@example.com",
190
+ * password: "secret123",
191
+ * rememberMe: true,
192
+ * });
193
+ *
194
+ * // Or specify a different method
195
+ * await submitter.submitJson(data, { method: "PUT" });
196
+ * ```
197
+ */
198
+ type SubmitJsonFunc<TModule extends RouteModule> = (
199
+ data: z.infer<TModule["formSchema"]>,
200
+ options?: SubmitJsonOptions,
201
+ ) => Promise<void>;
202
+
203
+ /**
204
+ * Form component type with pre-bound action URL.
205
+ *
206
+ * Renders a form element that automatically submits to the correct route.
207
+ * Method defaults to "POST" if not specified.
208
+ *
209
+ * @example
210
+ * ```typescript
211
+ * // Defaults to POST
212
+ * <submitter.Form>
213
+ * <input name="title" />
214
+ * <button type="submit">Submit</button>
215
+ * </submitter.Form>
216
+ *
217
+ * // Or specify a different method
218
+ * <submitter.Form method="PUT">
219
+ * ...
220
+ * </submitter.Form>
221
+ * ```
222
+ */
28
223
  type SubmitForm = (
29
224
  props: Omit<
30
225
  FetcherFormProps & React.RefAttributes<HTMLFormElement>,
31
226
  "action" | "method"
32
227
  > & {
33
- method: Exclude<SubmitOptions["method"], "GET">;
228
+ method?: Exclude<SubmitOptions["method"], "GET">;
34
229
  },
35
230
  ) => React.ReactElement;
36
231
 
232
+ /**
233
+ * Creates a type-safe fetcher for submitting forms to dynamic routes.
234
+ *
235
+ * This hook provides full TypeScript inference for:
236
+ * - Route parameters (from the route path)
237
+ * - Form data schema (from the route's formSchema export)
238
+ * - Action response type (from the route's action export)
239
+ *
240
+ * @template TInfo - The route module type (use `typeof import("./route-file")`)
241
+ *
242
+ * @param path - The route path (must match the route's `route` export)
243
+ * @param args - Route parameters (if the route has dynamic segments like `:id`)
244
+ *
245
+ * @returns An extended fetcher object with:
246
+ * - `submit` - Submit with FormData/SubmitTarget (includes schema type)
247
+ * - `submitJson` - Submit a plain object as JSON (schema type only)
248
+ * - `Form` - Pre-bound form component
249
+ * - `data` - Response data from the action (typed)
250
+ * - `state` - Fetcher state ("idle" | "loading" | "submitting")
251
+ * - All other useFetcher properties
252
+ *
253
+ * @example
254
+ * ### Basic usage with route parameters
255
+ *
256
+ * ```typescript
257
+ * // In your route file (app/routes/users.$userId.settings.tsx):
258
+ * export const route: RoutePath<"/users/:userId/settings"> = "/users/:userId/settings";
259
+ * export const formSchema = z.object({
260
+ * displayName: z.string().min(2),
261
+ * email: z.string().email(),
262
+ * notifications: z.boolean().default(true),
263
+ * });
264
+ * export const action = formAction({ schema: formSchema, handler: ... });
265
+ *
266
+ * // In your component:
267
+ * const submitter = useDynamicSubmitter<typeof import("./users.$userId.settings")>(
268
+ * "/users/:userId/settings",
269
+ * { userId: "123" }
270
+ * );
271
+ *
272
+ * // Submit using submitJson (type-safe, no FormData needed, defaults to POST)
273
+ * await submitter.submitJson({
274
+ * displayName: "John Doe",
275
+ * email: "john@example.com",
276
+ * notifications: true,
277
+ * });
278
+ *
279
+ * // Check the response
280
+ * if (submitter.data?.success) {
281
+ * console.log("Settings updated!");
282
+ * }
283
+ * ```
284
+ */
37
285
  export const useDynamicSubmitter = <TInfo extends RouteModule>(
38
286
  path: TInfo["route"],
39
287
  ...args: TInfo["route"] extends "undefined"
@@ -43,12 +291,16 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
43
291
  ReturnType<typeof useFetcher<TInfo["action"]>>,
44
292
  "load" | "submit" | "Form"
45
293
  > & {
294
+ /** Submit with FormData or SubmitTarget (schema type & SubmitTarget) */
46
295
  submit: SubmitFunc<TInfo>;
296
+ /** Submit a plain object as JSON (schema type only, defaults to POST) */
297
+ submitJson: SubmitJsonFunc<TInfo>;
298
+ /** Pre-bound Form component with action URL already set (defaults to POST) */
47
299
  Form: SubmitForm;
48
300
  } => {
49
301
  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);
302
+ // biome-ignore lint/suspicious/noExplicitAny: Intentional
303
+ return href(path, ...(args as any));
52
304
  }, [path, args]);
53
305
 
54
306
  const fetcher = useFetcher<TInfo["action"]>({
@@ -57,7 +309,6 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
57
309
 
58
310
  const submit: SubmitFunc<TInfo> = useCallback(
59
311
  (target, options) => {
60
- // console.log("Submitting form to", url, target, options);
61
312
  return fetcher.submit(target, {
62
313
  ...options,
63
314
  action: url,
@@ -67,11 +318,23 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
67
318
  [fetcher.submit, url],
68
319
  );
69
320
 
321
+ const submitJson: SubmitJsonFunc<TInfo> = useCallback(
322
+ (data, options = {}) => {
323
+ return fetcher.submit(data as SubmitTarget, {
324
+ ...options,
325
+ method: options.method ?? "POST",
326
+ action: url,
327
+ encType: "application/json",
328
+ });
329
+ },
330
+ [fetcher.submit, url],
331
+ );
332
+
70
333
  const OriginalForm = fetcher.Form;
71
334
 
72
335
  const Form: SubmitForm = useCallback(
73
- (props) => {
74
- return <OriginalForm action={url} {...props} />;
336
+ ({ method = "POST", ...props }) => {
337
+ return <OriginalForm action={url} method={method} {...props} />;
75
338
  },
76
339
  [url, OriginalForm],
77
340
  );
@@ -79,6 +342,7 @@ export const useDynamicSubmitter = <TInfo extends RouteModule>(
79
342
  return {
80
343
  ...fetcher,
81
344
  submit,
345
+ submitJson,
82
346
  Form,
83
347
  };
84
348
  };