@firtoz/router-toolkit 1.3.0 → 2.0.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
@@ -35,196 +35,645 @@ This package requires the following peer dependencies:
35
35
  }
36
36
  ```
37
37
 
38
- ## Hooks
38
+ ## Quick Start
39
39
 
40
- ### `useDynamicFetcher`
40
+ > **Prerequisites**: Make sure you have React Router 7 in framework mode set up. This toolkit requires the generated types from React Router's file-based routing.
41
+
42
+ ### 1. Setup Your Route Files
41
43
 
42
- Enhanced version of React Router's `useFetcher` with type safety and additional features.
44
+ Every route file needs to export a `route` constant for type inference:
43
45
 
44
46
  ```tsx
45
- import { useDynamicFetcher } from '@firtoz/router-toolkit';
47
+ // app/routes/users.tsx
48
+ import { useDynamicFetcher, type RoutePath } from '@firtoz/router-toolkit';
46
49
 
47
- function MyComponent() {
48
- const fetcher = useDynamicFetcher('/api/users');
50
+ export const route: RoutePath<"/users"> = "/users";
49
51
 
50
- const handleFetch = () => {
51
- // Basic fetch
52
- fetcher.load();
53
-
54
- // Fetch with query parameters
55
- fetcher.load({ page: '1', limit: '10' });
56
- };
52
+ export const loader = async () => {
53
+ return { users: [{ id: 1, name: "John" }] };
54
+ };
57
55
 
56
+ export default function UsersPage() {
57
+ const fetcher = useDynamicFetcher<typeof import("./users")>("/users");
58
+
58
59
  return (
59
60
  <div>
60
- {fetcher.state === 'loading' && <p>Loading...</p>}
61
+ <button onClick={() => fetcher.load()}>
62
+ {fetcher.state === "loading" ? "Loading..." : "Refresh"}
63
+ </button>
61
64
  {fetcher.data && <pre>{JSON.stringify(fetcher.data, null, 2)}</pre>}
62
- <button onClick={handleFetch}>Fetch Data</button>
63
65
  </div>
64
66
  );
65
67
  }
66
68
  ```
67
69
 
68
- ### `useCachedFetch`
70
+ ### 2. Use in Other Routes
71
+
72
+ ```tsx
73
+ // app/routes/dashboard.tsx
74
+ import { useEffect } from 'react';
75
+ import { useDynamicFetcher } from '@firtoz/router-toolkit';
76
+
77
+ export default function Dashboard() {
78
+ // Fetch data from the users route
79
+ const usersFetcher = useDynamicFetcher<typeof import("./users")>("/users");
80
+
81
+ useEffect(() => {
82
+ usersFetcher.load(); // Load users data
83
+ }, []);
84
+
85
+ return (
86
+ <div>
87
+ <h1>Dashboard</h1>
88
+ {usersFetcher.data?.users.map(user => (
89
+ <div key={user.id}>{user.name}</div>
90
+ ))}
91
+ </div>
92
+ );
93
+ }
94
+ ```
69
95
 
70
- Regular fetch-based hook that avoids route invalidation and provides caching.
96
+ ### 3. Forms with Actions
71
97
 
72
98
  ```tsx
73
- import { useCachedFetch } from '@firtoz/router-toolkit';
99
+ // app/routes/create-user.tsx
100
+ import { useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';
74
101
 
75
- function CachedComponent() {
76
- const { data, isLoading, error } = useCachedFetch('/api/static-data');
102
+ export const route: RoutePath<"/create-user"> = "/create-user";
77
103
 
78
- if (isLoading) return <div>Loading...</div>;
79
- if (error) return <div>Error: {error.message}</div>;
104
+ export async function action({ request }) {
105
+ const formData = await request.formData();
106
+ const name = formData.get("name");
107
+ return { success: true, user: { name } };
108
+ }
109
+
110
+ export default function CreateUser() {
111
+ const submitter = useDynamicSubmitter<typeof import("./create-user")>("/create-user");
80
112
 
81
- return <div>{JSON.stringify(data)}</div>;
113
+ return (
114
+ <submitter.Form method="post">
115
+ <input name="name" placeholder="User name" required />
116
+ <button type="submit">
117
+ {submitter.state === "submitting" ? "Creating..." : "Create"}
118
+ </button>
119
+ {submitter.data?.success && <p>✅ User created!</p>}
120
+ </submitter.Form>
121
+ );
82
122
  }
83
123
  ```
84
124
 
85
- ### `useDynamicSubmitter`
125
+ **Key Points:**
126
+ - Export `route: RoutePath<"your-path">` in every route file
127
+ - Use `useDynamicFetcher<typeof import("./route-file")>` for type-safe data fetching
128
+ - Use `useDynamicSubmitter<typeof import("./route-file")>` for type-safe form submission
129
+ - Full TypeScript inference for `fetcher.data` and `submitter.data`
86
130
 
87
- Type-safe form submission with Zod validation and enhanced submit functionality.
131
+ > **💡 Tip**: Start with `useDynamicFetcher` for data loading, then add `useDynamicSubmitter` for forms. The `useFetcherStateChanged` hook is great for notifications and side effects.
88
132
 
89
- **Basic Usage Pattern:**
133
+ ## Main Hooks
134
+
135
+ ### `useDynamicFetcher`
136
+
137
+ Enhanced version of React Router's `useFetcher` with type safety and query parameter support.
138
+
139
+ ```tsx
140
+ // app/routes/users.tsx
141
+ import { useDynamicFetcher, type RoutePath } from '@firtoz/router-toolkit';
142
+
143
+ export const route: RoutePath<"/users"> = "/users";
144
+
145
+ export const loader = async () => {
146
+ return {
147
+ users: [
148
+ { id: 1, name: "John Doe", email: "john@example.com" }
149
+ ],
150
+ timestamp: new Date().toISOString()
151
+ };
152
+ };
153
+
154
+ export default function UsersPage() {
155
+ const fetcher = useDynamicFetcher<typeof import("./users")>("/users");
156
+
157
+ const handleRefresh = () => {
158
+ fetcher.load(); // Basic fetch
159
+ };
160
+
161
+ const handleRefreshWithParams = () => {
162
+ fetcher.load({ page: "1", limit: "10", sort: "name" }); // With query params
163
+ };
164
+
165
+ return (
166
+ <div>
167
+ <button onClick={handleRefresh} disabled={fetcher.state === "loading"}>
168
+ {fetcher.state === "loading" ? "Loading..." : "Refresh Data"}
169
+ </button>
170
+
171
+ <button onClick={handleRefreshWithParams} disabled={fetcher.state === "loading"}>
172
+ Load with Filters
173
+ </button>
174
+
175
+ {fetcher.data && (
176
+ <div>
177
+ <h3>Users ({fetcher.data.users.length}):</h3>
178
+ <pre>{JSON.stringify(fetcher.data, null, 2)}</pre>
179
+ </div>
180
+ )}
181
+ </div>
182
+ );
183
+ }
184
+ ```
185
+
186
+ ### `useDynamicSubmitter`
187
+
188
+ Type-safe form submission with Zod validation and enhanced submit functionality. Works seamlessly with route modules for full type inference.
90
189
 
91
190
  ```tsx
92
191
  // app/routes/contact.tsx
93
192
  import { useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';
94
193
  import { z } from 'zod/v4';
194
+ import type { Route } from './+types/contact';
95
195
 
96
196
  // 1. Define your form schema
97
197
  export const formSchema = z.object({
98
- name: z.string(),
99
- email: z.string().email(),
198
+ name: z.string().min(1),
199
+ email: z.email(),
100
200
  });
101
201
 
102
202
  // 2. Export route constant
103
- export const route: RoutePath<"contact"> = "contact";
203
+ export const route: RoutePath<"/contact"> = "/contact";
104
204
 
105
205
  // 3. Define your action
106
- export const action = async ({ request }) => {
206
+ export async function action({ request }: Route.ActionArgs) {
107
207
  const formData = await request.formData();
108
- // Handle submission
109
- return { success: true };
110
- };
208
+ const name = formData.get("name") as string;
209
+ const email = formData.get("email") as string;
210
+
211
+ // Simple validation
212
+ if (!name || !email) {
213
+ return {
214
+ success: false,
215
+ message: "Name and email are required"
216
+ };
217
+ }
218
+
219
+ return {
220
+ success: true,
221
+ message: "Form submitted successfully!",
222
+ submittedData: { name, email }
223
+ };
224
+ }
111
225
 
112
- // 4. Use the hook (requires full route module setup)
226
+ // 4. Use the hook with typeof import for full type inference
113
227
  export default function ContactForm() {
114
- // Note: This requires proper route module registration
115
- const submitter = useDynamicSubmitter<{
116
- file: "contact";
117
- action: typeof action;
118
- formSchema: typeof formSchema;
119
- }>("contact");
228
+ const submitter = useDynamicSubmitter<typeof import("./contact")>("/contact");
120
229
 
121
230
  return (
122
- <submitter.Form method="POST">
123
- <input name="name" type="text" />
124
- <input name="email" type="email" />
125
- <button type="submit">Submit</button>
126
- </submitter.Form>
231
+ <div>
232
+ <submitter.Form method="post">
233
+ <div>
234
+ <label htmlFor="name">Name:</label>
235
+ <input
236
+ id="name"
237
+ name="name"
238
+ type="text"
239
+ required
240
+ />
241
+ </div>
242
+
243
+ <div>
244
+ <label htmlFor="email">Email:</label>
245
+ <input
246
+ id="email"
247
+ name="email"
248
+ type="email"
249
+ required
250
+ />
251
+ </div>
252
+
253
+ <button
254
+ type="submit"
255
+ disabled={submitter.state === "submitting"}
256
+ >
257
+ {submitter.state === "submitting" ? "Submitting..." : "Submit"}
258
+ </button>
259
+ </submitter.Form>
260
+
261
+ {submitter.data && (
262
+ <div>
263
+ {submitter.data.success ? (
264
+ <p>✅ {submitter.data.message}</p>
265
+ ) : (
266
+ <p>❌ {submitter.data.message}</p>
267
+ )}
268
+ </div>
269
+ )}
270
+ </div>
127
271
  );
128
272
  }
129
273
  ```
130
274
 
131
- **Note:** `useDynamicSubmitter` requires advanced setup with route module registration and Zod schemas. For simpler use cases, you may prefer React Router's built-in `useFetcher`.
132
-
133
275
  ### `useFetcherStateChanged`
134
276
 
135
- Track changes in fetcher state and react to them.
277
+ Track changes in fetcher state and react to them. Perfect for triggering side effects, showing notifications, or handling state transitions in your application.
136
278
 
137
279
  ```tsx
138
- import { useFetcher } from 'react-router';
139
- import { useFetcherStateChanged } from '@firtoz/router-toolkit';
280
+ // app/routes/notification-form.tsx
281
+ import { useDynamicSubmitter, useFetcherStateChanged, type RoutePath } from '@firtoz/router-toolkit';
282
+ import { useState } from 'react';
283
+ import { z } from 'zod/v4';
284
+ import type { Route } from './+types/notification-form';
285
+
286
+ export const route: RoutePath<"/notification-form"> = "/notification-form";
287
+
288
+ export const formSchema = z.object({
289
+ message: z.string().min(1),
290
+ type: z.enum(["info", "warning", "error"]),
291
+ });
140
292
 
141
- function StateTracker() {
142
- const fetcher = useFetcher();
293
+ export async function action({ request }: Route.ActionArgs) {
294
+ const formData = await request.formData();
295
+ const message = formData.get("message") as string;
296
+ const type = formData.get("type") as string;
297
+
298
+ // Simulate processing
299
+ await new Promise(resolve => setTimeout(resolve, 1000));
143
300
 
144
- useFetcherStateChanged(fetcher, (lastState, newState) => {
145
- console.log(`State changed from ${lastState} to ${newState}`);
301
+ return {
302
+ success: true,
303
+ message: "Notification sent!",
304
+ data: { message, type }
305
+ };
306
+ }
307
+
308
+ export default function NotificationForm() {
309
+ const submitter = useDynamicSubmitter<typeof import("./notification-form")>("/notification-form");
310
+ const [notifications, setNotifications] = useState<string[]>([]);
311
+
312
+ // Track fetcher state changes for side effects
313
+ useFetcherStateChanged(submitter, (lastState, newState) => {
314
+ console.log(`Fetcher state changed from ${lastState} to ${newState}`);
146
315
 
316
+ // Show success notification when form submission completes
147
317
  if (newState === 'idle' && lastState === 'submitting') {
148
- // Handle successful submission
149
- console.log('Form submitted successfully!');
318
+ if (submitter.data?.success) {
319
+ setNotifications(prev => [...prev, `✅ ${submitter.data.message}`]);
320
+ } else {
321
+ setNotifications(prev => [...prev, `❌ Submission failed`]);
322
+ }
323
+ }
324
+
325
+ // Clear notifications when starting new submission
326
+ if (newState === 'submitting' && lastState === 'idle') {
327
+ setNotifications([]);
150
328
  }
151
329
  });
152
330
 
153
331
  return (
154
- <fetcher.Form method="POST" action="/api/submit">
155
- <button type="submit">Submit</button>
156
- <p>Current state: {fetcher.state}</p>
157
- </fetcher.Form>
332
+ <div>
333
+ <h1>Send Notification</h1>
334
+
335
+ <submitter.Form method="post">
336
+ <div>
337
+ <label htmlFor="message">Message:</label>
338
+ <input
339
+ id="message"
340
+ name="message"
341
+ type="text"
342
+ required
343
+ />
344
+ </div>
345
+
346
+ <div>
347
+ <label htmlFor="type">Type:</label>
348
+ <select id="type" name="type" required>
349
+ <option value="info">Info</option>
350
+ <option value="warning">Warning</option>
351
+ <option value="error">Error</option>
352
+ </select>
353
+ </div>
354
+
355
+ <button
356
+ type="submit"
357
+ disabled={submitter.state === 'submitting'}
358
+ >
359
+ {submitter.state === 'submitting' ? 'Sending...' : 'Send Notification'}
360
+ </button>
361
+
362
+ <p>Current state: <strong>{submitter.state}</strong></p>
363
+ </submitter.Form>
364
+
365
+ {/* Show notifications triggered by state changes */}
366
+ {notifications.length > 0 && (
367
+ <div style={{ marginTop: '20px' }}>
368
+ <h3>Notifications:</h3>
369
+ {notifications.map((notification, index) => (
370
+ <div key={index} style={{ padding: '5px', margin: '5px 0', backgroundColor: '#f0f0f0' }}>
371
+ {notification}
372
+ </div>
373
+ ))}
374
+ </div>
375
+ )}
376
+ </div>
158
377
  );
159
378
  }
160
379
  ```
161
380
 
162
- ## Type Helpers
381
+ **Common Use Cases:**
382
+
383
+ - **Notifications**: Show success/error messages after form submissions
384
+ - **Analytics**: Track form submission events and user interactions
385
+ - **UI Updates**: Update other parts of the UI based on fetcher state
386
+ - **Side Effects**: Trigger API calls, redirects, or other actions on state changes
387
+ - **Debugging**: Log state transitions for debugging purposes
163
388
 
164
- ### `Func`
389
+ **State Transitions:**
390
+ - `idle` → `submitting`: Form submission started
391
+ - `submitting` → `idle`: Form submission completed (check `fetcher.data` for results)
392
+ - `idle` → `loading`: Data fetching started (with `useDynamicFetcher`)
393
+ - `loading` → `idle`: Data fetching completed
165
394
 
166
- Generic function type helper for route loaders and actions.
395
+ ## Form Action Utilities
396
+
397
+ ### `formAction`
398
+
399
+ Type-safe form action wrapper that provides Zod validation and structured error handling for React Router actions. This utility integrates seamlessly with `useDynamicSubmitter` and the `formSchema` export pattern.
400
+
401
+ #### Features
402
+
403
+ - ✅ **Automatic form data validation** using Zod schemas
404
+ - 🛡️ **Type-safe error handling** with structured error types
405
+ - 🔄 **MaybeError integration** for consistent error patterns
406
+ - 🚀 **React Router compatibility** preserves redirects and responses
407
+ - 📝 **Full TypeScript support** with inferred types from schemas
408
+
409
+ #### Basic Usage
167
410
 
168
411
  ```tsx
169
- import type { Func } from '@firtoz/router-toolkit/types';
412
+ // app/routes/register.tsx
413
+ import { z } from "zod/v4";
414
+ import { formAction, type RoutePath } from "@firtoz/router-toolkit";
415
+ import { success, fail } from "@firtoz/maybe-error";
170
416
 
171
- // Usage in route modules
172
- type RouteModule = {
173
- file: keyof Register["pages"];
174
- loader: Func;
175
- };
417
+ // Export the schema for useDynamicSubmitter integration
418
+ export const formSchema = z.object({
419
+ email: z.string().email("Invalid email format"),
420
+ password: z.string().min(8, "Password must be at least 8 characters"),
421
+ confirmPassword: z.string(),
422
+ }).refine(data => data.password === data.confirmPassword, {
423
+ message: "Passwords don't match",
424
+ path: ["confirmPassword"],
425
+ });
426
+
427
+ export const action = formAction({
428
+ schema: formSchema,
429
+ handler: async (args, data) => {
430
+ // data is fully typed based on the schema
431
+ try {
432
+ const user = await createUser({
433
+ email: data.email,
434
+ password: data.password,
435
+ });
436
+
437
+ return success({
438
+ message: "Registration successful!",
439
+ userId: user.id,
440
+ });
441
+ } catch (error) {
442
+ return fail("Email already exists");
443
+ }
444
+ },
445
+ });
446
+
447
+ export const route: RoutePath<"/register"> = "/register";
176
448
  ```
177
449
 
178
- ### `HrefArgs`
450
+ #### Using with useDynamicSubmitter
179
451
 
180
- Type helper for extracting href arguments from route paths.
452
+ The `formAction` utility works seamlessly with `useDynamicSubmitter` when you export a `formSchema`:
181
453
 
182
454
  ```tsx
183
- import type { HrefArgs } from '@firtoz/router-toolkit/types';
455
+ // app/routes/register.tsx (component)
456
+ import { useDynamicSubmitter } from "@firtoz/router-toolkit";
184
457
 
185
- // Usage for type-safe routing
186
- type ProfileArgs = HrefArgs<'/profile/:id'>;
187
- // ProfileArgs is [{ id: string }]
458
+ export default function Register() {
459
+ const submitter = useDynamicSubmitter<typeof import("./register")>("/register");
460
+
461
+ return (
462
+ <submitter.Form method="post">
463
+ <input name="email" type="email" required />
464
+ <input name="password" type="password" required />
465
+ <input name="confirmPassword" type="password" required />
466
+ <button type="submit" disabled={submitter.state === "submitting"}>
467
+ {submitter.state === "submitting" ? "Registering..." : "Register"}
468
+ </button>
469
+ </submitter.Form>
470
+ );
471
+ }
188
472
  ```
189
473
 
190
- ## Usage with React Router 7 Framework Mode
474
+ #### Error Handling
475
+
476
+ The `formAction` utility returns structured errors that you can handle in your components:
191
477
 
192
- This toolkit is specifically designed for React Router 7's framework mode. Here's the recommended pattern for setting up routes with router-toolkit:
478
+ ```tsx
479
+ export default function Register() {
480
+ const submitter = useDynamicSubmitter<typeof import("./register")>("/register");
481
+
482
+ if (submitter.data && !submitter.data.success) {
483
+ const error = submitter.data.error;
484
+
485
+ switch (error.type) {
486
+ case "validation":
487
+ // Handle Zod validation errors
488
+ console.log("Validation errors:", error.error);
489
+ break;
490
+ case "handler":
491
+ // Handle business logic errors
492
+ console.log("Handler error:", error.error);
493
+ break;
494
+ case "unknown":
495
+ // Handle unexpected errors
496
+ console.log("Unknown error occurred");
497
+ break;
498
+ }
499
+ }
193
500
 
194
- ### Route Setup Pattern
501
+ // Rest of component...
502
+ }
503
+ ```
195
504
 
196
- For each route file, follow this pattern to enable full type safety:
505
+ #### Error Types
506
+
507
+ The `formAction` utility returns three types of errors:
508
+
509
+ 1. **Validation Errors** (`type: "validation"`)
510
+ - Occurs when form data doesn't match the Zod schema
511
+ - Contains detailed field-level validation errors from Zod
512
+ - The `error.error` field contains the result of `z.treeifyError()`
513
+
514
+ 2. **Handler Errors** (`type: "handler"`)
515
+ - Occurs when your handler function returns a `fail()` result
516
+ - Contains the custom error you provided to `fail()`
517
+ - The `error.error` field contains your custom error value
518
+
519
+ 3. **Unknown Errors** (`type: "unknown"`)
520
+ - Occurs when an unexpected exception is thrown
521
+ - Logs the error to console for debugging
522
+ - Does not expose the raw error to avoid information leakage
523
+
524
+ #### Advanced Features
525
+
526
+ **File Uploads**
197
527
 
198
528
  ```tsx
199
- // app/routes/users.tsx
200
- import { useDynamicFetcher, type RoutePath } from '@firtoz/router-toolkit';
529
+ const uploadSchema = z.object({
530
+ title: z.string().min(1),
531
+ file: z.instanceof(File),
532
+ description: z.string().optional(),
533
+ });
201
534
 
202
- // 1. Export your route constant with proper typing
203
- export const route: RoutePath<"users"> = "users";
535
+ export const action = formAction({
536
+ schema: uploadSchema,
537
+ handler: async (args, data) => {
538
+ const uploadResult = await uploadFile(data.file, {
539
+ title: data.title,
540
+ description: data.description,
541
+ });
542
+
543
+ return success({ fileId: uploadResult.id });
544
+ },
545
+ });
546
+ ```
204
547
 
205
- // 2. Define your loader/action as usual
206
- export const loader = async () => {
207
- return { users: [] }; // Your data
208
- };
548
+ **Complex Validation**
209
549
 
210
- // 3. Use the hook with typeof import for full type inference
211
- export default function UsersPage() {
212
- const fetcher = useDynamicFetcher<typeof import("./users")>("users");
550
+ ```tsx
551
+ const complexSchema = z.object({
552
+ user: z.object({
553
+ name: z.string().min(2),
554
+ age: z.coerce.number().min(18),
555
+ }),
556
+ preferences: z.object({
557
+ newsletter: z.boolean().default(false),
558
+ theme: z.enum(["light", "dark"]).default("light"),
559
+ }),
560
+ terms: z.literal("on", {
561
+ errorMap: () => ({ message: "You must accept the terms" })
562
+ }),
563
+ });
564
+ ```
213
565
 
214
- const handleRefresh = () => {
215
- fetcher.load(); // No need to specify URL - it's inferred
566
+ **Redirects and Responses**
567
+
568
+ React Router `Response` objects (like redirects) are automatically preserved:
569
+
570
+ ```tsx
571
+ export const action = formAction({
572
+ schema: loginSchema,
573
+ handler: async (args, data) => {
574
+ const user = await authenticateUser(data.email, data.password);
575
+
576
+ if (user) {
577
+ // This redirect will be properly handled by React Router
578
+ throw redirect("/dashboard");
579
+ }
580
+
581
+ return fail("Invalid credentials");
582
+ },
583
+ });
584
+ ```
585
+
586
+ #### Type Safety
587
+
588
+ The `formAction` utility provides full type safety:
589
+
590
+ - **Schema inference**: Form data is typed based on your Zod schema
591
+ - **Handler types**: Handler parameters are properly typed
592
+ - **Error types**: Error handling is type-safe with discriminated unions
593
+ - **Integration**: Works seamlessly with `useDynamicSubmitter` type inference
594
+
595
+ #### API Reference
596
+
597
+ ```tsx
598
+ function formAction<
599
+ TSchema extends z.ZodTypeAny,
600
+ TResult = undefined,
601
+ TError = string,
602
+ ActionArgs extends ActionFunctionArgs = ActionFunctionArgs,
603
+ >(config: {
604
+ schema: TSchema;
605
+ handler: (
606
+ args: ActionArgs,
607
+ data: z.infer<TSchema>
608
+ ) => Promise<MaybeError<TResult, TError>>;
609
+ }): (args: ActionArgs) => Promise<MaybeError<TResult, FormActionError<TError>>>;
610
+
611
+ type FormActionError<TError> =
612
+ | { type: "validation"; error: ReturnType<typeof z.treeifyError> }
613
+ | { type: "handler"; error: TError }
614
+ | { type: "unknown" };
615
+ ```
616
+
617
+ ## Type Utilities
618
+
619
+ ### `RoutePath<T>`
620
+
621
+ Type-safe route path helper that ensures you're using valid route paths from your React Router configuration.
622
+
623
+ ```tsx
624
+ import type { RoutePath } from '@firtoz/router-toolkit';
625
+
626
+ // Ensures "/users" is a valid route in your app
627
+ export const route: RoutePath<"/users"> = "/users";
628
+
629
+ // TypeScript error if route doesn't exist
630
+ export const invalidRoute: RoutePath<"/non-existent"> = "/non-existent"; // ❌ Error
631
+ ```
632
+
633
+ This is the main type utility you'll use. It provides compile-time validation that your route paths actually exist in your React Router configuration.
634
+
635
+ ## Additional Utilities
636
+
637
+ ### `useCachedFetch`
638
+
639
+ Alternative to `useDynamicFetcher` that uses standard `fetch()` instead of React Router's fetcher system. Provides automatic caching and avoids route invalidation.
640
+
641
+ ```tsx
642
+ // app/routes/config.tsx
643
+ import { useCachedFetch, type RoutePath } from '@firtoz/router-toolkit';
644
+
645
+ export const route: RoutePath<"/config"> = "/config";
646
+
647
+ export const loader = async () => {
648
+ return {
649
+ apiUrl: "https://api.example.com",
650
+ version: "1.0.0",
651
+ features: ["auth", "payments"]
216
652
  };
653
+ };
217
654
 
655
+ export default function ConfigPage() {
656
+ const { data, isLoading, error } = useCachedFetch<typeof import("./config")>("/config");
657
+
658
+ if (isLoading) return <div>Loading...</div>;
659
+ if (error) return <div>Error: {error.message}</div>;
660
+
218
661
  return (
219
662
  <div>
220
- <button onClick={handleRefresh}>Refresh</button>
221
- {fetcher.data && <div>{JSON.stringify(fetcher.data)}</div>}
663
+ <h1>Configuration</h1>
664
+ <p>API: {data?.apiUrl}</p>
665
+ <p>Version: {data?.version}</p>
222
666
  </div>
223
667
  );
224
668
  }
225
669
  ```
226
670
 
227
- ### Configuration
671
+ **When to use `useCachedFetch` vs `useDynamicFetcher`:**
672
+
673
+ - **`useCachedFetch`**: Static data, configuration, content that rarely changes
674
+ - **`useDynamicFetcher`**: Dynamic data, user-specific content, data that changes frequently
675
+
676
+ ## Configuration
228
677
 
229
678
  Make sure your routes are properly typed in your `react-router.config.ts`:
230
679
 
@@ -239,52 +688,197 @@ export default {
239
688
  // This will generate the Register types that the toolkit relies on
240
689
  ```
241
690
 
242
- ## Examples
691
+ ## Real-World Examples
692
+
693
+ These examples are based on actual usage patterns from the router-toolkit test application. Each example is complete and can be copied directly into your project.
694
+
695
+ > **🚀 Quick Copy**: Each example below is a complete, working route file. Copy the entire code block to get started immediately.
696
+
697
+ ### Data Loading with Refresh (Loader Test Pattern)
698
+
699
+ ```tsx
700
+ // app/routes/loader-test.tsx
701
+ import { useDynamicFetcher, type RoutePath } from '@firtoz/router-toolkit';
702
+
703
+ interface LoaderData {
704
+ user: {
705
+ id: number;
706
+ name: string;
707
+ email: string;
708
+ };
709
+ timestamp: string;
710
+ }
711
+
712
+ export const route: RoutePath<"/loader-test"> = "/loader-test";
713
+
714
+ export const loader = async (): Promise<LoaderData> => {
715
+ // Simulate API call delay
716
+ await new Promise((resolve) => setTimeout(resolve, 500));
717
+
718
+ return {
719
+ user: {
720
+ id: 1,
721
+ name: "John Doe",
722
+ email: "john@example.com",
723
+ },
724
+ timestamp: new Date().toISOString(),
725
+ };
726
+ };
727
+
728
+ export default function LoaderTest() {
729
+ const fetcher = useDynamicFetcher<typeof import("./loader-test")>("/loader-test");
243
730
 
244
- ### Complete Form with Validation
731
+ const handleRefresh = () => {
732
+ fetcher.load();
733
+ };
734
+
735
+ return (
736
+ <div className="p-6">
737
+ <h1 className="text-2xl font-bold mb-4">Loader Test</h1>
738
+ <p className="mb-4">Testing React Router useFetcher hook</p>
739
+
740
+ <button
741
+ type="button"
742
+ onClick={handleRefresh}
743
+ disabled={fetcher.state === "loading"}
744
+ className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
745
+ >
746
+ {fetcher.state === "loading" ? "Loading..." : "Refresh Data"}
747
+ </button>
748
+
749
+ <div className="mt-6">
750
+ <h2 className="text-lg font-semibold mb-2">Fetcher State:</h2>
751
+ <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
752
+ {JSON.stringify({ state: fetcher.state }, null, 2)}
753
+ </pre>
754
+ </div>
755
+
756
+ {fetcher.data && (
757
+ <div className="mt-6">
758
+ <h2 className="text-lg font-semibold mb-2">Fetched Data:</h2>
759
+ <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
760
+ {JSON.stringify(fetcher.data, null, 2)}
761
+ </pre>
762
+ </div>
763
+ )}
764
+
765
+ {fetcher.state === "idle" && fetcher.data && (
766
+ <div className="mt-4 p-3 bg-green-100 rounded">
767
+ <p className="text-green-800">✅ Data loaded successfully!</p>
768
+ </div>
769
+ )}
770
+ </div>
771
+ );
772
+ }
773
+ ```
774
+
775
+ ### Form Submission (Action Test Pattern)
245
776
 
246
777
  ```tsx
247
- import { useDynamicSubmitter } from '@firtoz/router-toolkit';
778
+ // app/routes/action-test.tsx
779
+ import { useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';
248
780
  import { z } from 'zod/v4';
781
+ import type { Route } from './+types/action-test';
782
+
783
+ interface ActionData {
784
+ success: boolean;
785
+ message: string;
786
+ submittedData?: {
787
+ name: string;
788
+ email: string;
789
+ };
790
+ }
791
+
792
+ export const route: RoutePath<"/action-test"> = "/action-test";
249
793
 
250
- const userSchema = z.object({
251
- name: z.string().min(1, 'Name is required'),
252
- email: z.string().email('Invalid email'),
253
- age: z.number().min(18, 'Must be 18 or older'),
794
+ export const formSchema = z.object({
795
+ name: z.string().min(1),
796
+ email: z.email(),
254
797
  });
255
798
 
256
- function UserForm() {
257
- const submitter = useDynamicSubmitter('/api/users');
799
+ export async function action({ request }: Route.ActionArgs): Promise<ActionData> {
800
+ const formData = await request.formData();
801
+ const name = formData.get("name") as string;
802
+ const email = formData.get("email") as string;
803
+
804
+ // Simulate processing delay
805
+ await new Promise((resolve) => setTimeout(resolve, 1000));
806
+
807
+ // Simple validation
808
+ if (!name || !email) {
809
+ return {
810
+ success: false,
811
+ message: "Name and email are required",
812
+ };
813
+ }
814
+
815
+ return {
816
+ success: true,
817
+ message: "Form submitted successfully!",
818
+ submittedData: { name, email },
819
+ };
820
+ }
821
+
822
+ export default function ActionTest() {
823
+ const submitter = useDynamicSubmitter<typeof import("./action-test")>("/action-test");
258
824
 
259
825
  return (
260
- <div>
261
- <h2>Create User</h2>
262
-
263
- <submitter.Form method="POST">
264
- <div>
265
- <label htmlFor="name">Name:</label>
266
- <input name="name" type="text" required />
267
- </div>
268
-
826
+ <div className="p-6">
827
+ <h1 className="text-2xl font-bold mb-4">Action Test</h1>
828
+ <p className="mb-4">Testing React Router form actions</p>
829
+
830
+ <submitter.Form method="post" className="space-y-4 max-w-md">
269
831
  <div>
270
- <label htmlFor="email">Email:</label>
271
- <input name="email" type="email" required />
832
+ <label htmlFor="name" className="block text-sm font-medium mb-1">
833
+ Name:
834
+ </label>
835
+ <input
836
+ id="name"
837
+ name="name"
838
+ type="text"
839
+ required
840
+ className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
841
+ />
272
842
  </div>
273
-
843
+
274
844
  <div>
275
- <label htmlFor="age">Age:</label>
276
- <input name="age" type="number" required />
845
+ <label htmlFor="email" className="block text-sm font-medium mb-1">
846
+ Email:
847
+ </label>
848
+ <input
849
+ id="email"
850
+ name="email"
851
+ type="email"
852
+ required
853
+ className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
854
+ />
277
855
  </div>
278
-
279
- <button type="submit" disabled={submitter.state === 'submitting'}>
280
- {submitter.state === 'submitting' ? 'Creating...' : 'Create User'}
856
+
857
+ <button
858
+ type="submit"
859
+ disabled={submitter.state === "submitting"}
860
+ className="bg-green-500 text-white px-4 py-2 rounded disabled:opacity-50"
861
+ >
862
+ {submitter.state === "submitting" ? "Submitting..." : "Submit"}
281
863
  </button>
282
864
  </submitter.Form>
283
865
 
284
866
  {submitter.data && (
285
- <div>
286
- <h3>Success!</h3>
287
- <p>User created: {JSON.stringify(submitter.data)}</p>
867
+ <div className="mt-6">
868
+ <h2 className="text-lg font-semibold mb-2">Action Result:</h2>
869
+ <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
870
+ {JSON.stringify(submitter.data, null, 2)}
871
+ </pre>
872
+
873
+ {submitter.data.success ? (
874
+ <div className="mt-4 p-3 bg-green-100 rounded">
875
+ <p className="text-green-800">✅ {submitter.data.message}</p>
876
+ </div>
877
+ ) : (
878
+ <div className="mt-4 p-3 bg-red-100 rounded">
879
+ <p className="text-red-800">❌ {submitter.data.message}</p>
880
+ </div>
881
+ )}
288
882
  </div>
289
883
  )}
290
884
  </div>
@@ -292,61 +886,457 @@ function UserForm() {
292
886
  }
293
887
  ```
294
888
 
295
- ### Data Fetching with Error Handling
889
+ ### Combined Loader and Action (Full CRUD Pattern)
296
890
 
297
891
  ```tsx
298
- import { useDynamicFetcher, useFetcherStateChanged } from '@firtoz/router-toolkit';
299
- import { useEffect, useState } from 'react';
300
-
301
- function UserList() {
302
- const fetcher = useDynamicFetcher('/api/users');
303
- const [error, setError] = useState<string | null>(null);
304
-
305
- useFetcherStateChanged(fetcher, (lastState, newState) => {
306
- if (newState === 'idle' && fetcher.data?.error) {
307
- setError(fetcher.data.error);
308
- } else if (newState === 'loading') {
309
- setError(null);
310
- }
311
- });
892
+ // app/routes/combined-test.tsx
893
+ import {
894
+ useDynamicFetcher,
895
+ useDynamicSubmitter,
896
+ type RoutePath,
897
+ } from '@firtoz/router-toolkit';
898
+ import { useLoaderData } from 'react-router';
899
+ import { z } from 'zod/v4';
900
+ import type { Route } from './+types/combined-test';
312
901
 
313
- useEffect(() => {
314
- fetcher.load();
315
- }, []);
902
+ interface User {
903
+ id: number;
904
+ name: string;
905
+ email: string;
906
+ lastUpdated: string;
907
+ }
908
+
909
+ interface LoaderData {
910
+ user: User;
911
+ }
912
+
913
+ type ActionData = {
914
+ success: boolean;
915
+ message: string;
916
+ updatedUser?: User;
917
+ };
316
918
 
317
- const refetch = () => {
318
- fetcher.load({ refresh: 'true' });
919
+ export const route: RoutePath<"/combined-test"> = "/combined-test";
920
+
921
+ export const formSchema = z.object({
922
+ name: z.string().min(1),
923
+ email: z.email(),
924
+ });
925
+
926
+ export const loader = async (): Promise<LoaderData> => {
927
+ await new Promise((resolve) => setTimeout(resolve, 300));
928
+
929
+ return {
930
+ user: {
931
+ id: 1,
932
+ name: "John Doe",
933
+ email: "john@example.com",
934
+ lastUpdated: new Date().toISOString(),
935
+ },
936
+ };
937
+ };
938
+
939
+ export async function action({ request }: Route.ActionArgs): Promise<ActionData> {
940
+ const formData = await request.formData();
941
+ const name = formData.get("name") as string;
942
+ const email = formData.get("email") as string;
943
+
944
+ await new Promise((resolve) => setTimeout(resolve, 500));
945
+
946
+ if (!name || !email) {
947
+ return {
948
+ success: false,
949
+ message: "Name and email are required",
950
+ };
951
+ }
952
+
953
+ const updatedUser: User = {
954
+ id: 1,
955
+ name,
956
+ email,
957
+ lastUpdated: new Date().toISOString(),
319
958
  };
320
959
 
960
+ return {
961
+ success: true,
962
+ message: "User updated successfully!",
963
+ updatedUser,
964
+ };
965
+ }
966
+
967
+ export default function CombinedTest() {
968
+ const loaderData = useLoaderData<LoaderData>();
969
+ const fetcher = useDynamicFetcher<typeof import("./combined-test")>("/combined-test");
970
+ const submitter = useDynamicSubmitter<typeof import("./combined-test")>("/combined-test");
971
+
321
972
  return (
322
- <div>
323
- <div style={{ display: 'flex', justifyContent: 'space-between' }}>
324
- <h2>Users</h2>
325
- <button onClick={refetch} disabled={fetcher.state === 'loading'}>
326
- {fetcher.state === 'loading' ? 'Loading...' : 'Refresh'}
327
- </button>
973
+ <div className="p-6">
974
+ <h1 className="text-2xl font-bold mb-4">Combined Test</h1>
975
+ <p className="mb-4">Testing both loader data and form actions</p>
976
+
977
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
978
+ {/* Loader Data Section */}
979
+ <div>
980
+ <h2 className="text-lg font-semibold mb-3">Current User Data</h2>
981
+ <div className="bg-blue-50 p-4 rounded">
982
+ <h3 className="font-medium">Loaded from Server:</h3>
983
+ <pre className="mt-2 text-sm bg-gray-200 p-3 rounded text-gray-800">
984
+ {JSON.stringify(loaderData.user, null, 2)}
985
+ </pre>
986
+ </div>
987
+ </div>
988
+
989
+ {/* Action Form Section */}
990
+ <div>
991
+ <h2 className="text-lg font-semibold mb-3">Update User</h2>
992
+ <submitter.Form method="post" className="space-y-4">
993
+ <div>
994
+ <label htmlFor="name" className="block text-sm font-medium mb-1">
995
+ Name:
996
+ </label>
997
+ <input
998
+ id="name"
999
+ name="name"
1000
+ type="text"
1001
+ defaultValue={loaderData.user.name}
1002
+ required
1003
+ className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
1004
+ />
1005
+ </div>
1006
+
1007
+ <div>
1008
+ <label htmlFor="email" className="block text-sm font-medium mb-1">
1009
+ Email:
1010
+ </label>
1011
+ <input
1012
+ id="email"
1013
+ name="email"
1014
+ type="email"
1015
+ defaultValue={loaderData.user.email}
1016
+ required
1017
+ className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
1018
+ />
1019
+ </div>
1020
+
1021
+ <button
1022
+ type="submit"
1023
+ disabled={submitter.state === "submitting"}
1024
+ className="bg-purple-500 text-white px-4 py-2 rounded disabled:opacity-50"
1025
+ >
1026
+ {submitter.state === "submitting" ? "Updating..." : "Update User"}
1027
+ </button>
1028
+ </submitter.Form>
1029
+ </div>
328
1030
  </div>
329
1031
 
330
- {error && (
331
- <div style={{ color: 'red', padding: '10px', background: '#fee' }}>
332
- Error: {error}
1032
+ {/* Status Section */}
1033
+ <div className="mt-6">
1034
+ <h2 className="text-lg font-semibold mb-2">Action Status:</h2>
1035
+ <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
1036
+ {JSON.stringify({ state: submitter.state }, null, 2)}
1037
+ </pre>
1038
+ </div>
1039
+
1040
+ {submitter.data && (
1041
+ <div className="mt-6">
1042
+ <h2 className="text-lg font-semibold mb-2">Action Result:</h2>
1043
+ <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
1044
+ {JSON.stringify(submitter.data, null, 2)}
1045
+ </pre>
1046
+
1047
+ {submitter.data.success ? (
1048
+ <div className="mt-4 p-3 bg-green-100 rounded">
1049
+ <p className="text-green-800">✅ {submitter.data.message}</p>
1050
+ {submitter.data.updatedUser && (
1051
+ <p className="text-sm text-green-700 mt-1">
1052
+ Tip: Reload the page to see if data persists (it won't in this demo)
1053
+ </p>
1054
+ )}
1055
+ </div>
1056
+ ) : (
1057
+ <div className="mt-4 p-3 bg-red-100 rounded">
1058
+ <p className="text-red-800">❌ {submitter.data.message}</p>
1059
+ </div>
1060
+ )}
333
1061
  </div>
334
1062
  )}
1063
+ </div>
1064
+ );
1065
+ }
1066
+ ```
335
1067
 
336
- {fetcher.data?.users && (
337
- <ul>
338
- {fetcher.data.users.map((user: any) => (
339
- <li key={user.id}>
340
- {user.name} ({user.email})
341
- </li>
342
- ))}
343
- </ul>
1068
+ ## MaybeError Utility
1069
+
1070
+ The router-toolkit includes the `@firtoz/maybe-error` package, which provides type-safe error handling utilities using discriminated unions. This is perfect for handling operations that may fail in your route loaders and actions.
1071
+
1072
+ ### Basic Usage
1073
+
1074
+ ```tsx
1075
+ import { success, fail, type MaybeError } from '@firtoz/router-toolkit';
1076
+
1077
+ // Define a function that may fail
1078
+ function divide(a: number, b: number): MaybeError<number> {
1079
+ if (b === 0) {
1080
+ return fail("Division by zero");
1081
+ }
1082
+ return success(a / b);
1083
+ }
1084
+
1085
+ // Type-safe error handling
1086
+ const result = divide(10, 2);
1087
+ if (result.success) {
1088
+ console.log(result.result); // 5 - TypeScript knows this is a number
1089
+ } else {
1090
+ console.error(result.error); // "Division by zero" - TypeScript knows this is a string
1091
+ }
1092
+ ```
1093
+
1094
+ ### Route Loader with Error Handling
1095
+
1096
+ ```tsx
1097
+ // app/routes/user-profile.tsx
1098
+ import { success, fail, type MaybeError, type RoutePath } from '@firtoz/router-toolkit';
1099
+ import type { Route } from './+types/user-profile';
1100
+
1101
+ interface User {
1102
+ id: string;
1103
+ name: string;
1104
+ email: string;
1105
+ }
1106
+
1107
+ interface ApiError {
1108
+ code: number;
1109
+ message: string;
1110
+ }
1111
+
1112
+ export const route: RoutePath<"/user-profile/:id"> = "/user-profile/:id";
1113
+
1114
+ // Loader that returns MaybeError for type-safe error handling
1115
+ export const loader = async ({ params }: Route.LoaderArgs): Promise<MaybeError<User, ApiError>> => {
1116
+ try {
1117
+ const response = await fetch(`/api/users/${params.id}`);
1118
+
1119
+ if (!response.ok) {
1120
+ return fail({
1121
+ code: response.status,
1122
+ message: response.status === 404 ? "User not found" : "Failed to fetch user"
1123
+ });
1124
+ }
1125
+
1126
+ const user = await response.json();
1127
+ return success(user);
1128
+ } catch (error) {
1129
+ return fail({
1130
+ code: 500,
1131
+ message: "Network error occurred"
1132
+ });
1133
+ }
1134
+ };
1135
+
1136
+ export default function UserProfile() {
1137
+ const fetcher = useDynamicFetcher<typeof import("./user-profile")>("/user-profile/:id", { id: "123" });
1138
+
1139
+ // Handle the MaybeError result
1140
+ if (!fetcher.data) {
1141
+ return <div>Loading...</div>;
1142
+ }
1143
+
1144
+ if (!fetcher.data.success) {
1145
+ return (
1146
+ <div className="error">
1147
+ <h2>Error {fetcher.data.error.code}</h2>
1148
+ <p>{fetcher.data.error.message}</p>
1149
+ </div>
1150
+ );
1151
+ }
1152
+
1153
+ return (
1154
+ <div>
1155
+ <h1>{fetcher.data.result.name}</h1>
1156
+ <p>Email: {fetcher.data.result.email}</p>
1157
+ </div>
1158
+ );
1159
+ }
1160
+ ```
1161
+
1162
+ ### Action with Error Handling
1163
+
1164
+ ```tsx
1165
+ // app/routes/create-user.tsx
1166
+ import { success, fail, type MaybeError, useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';
1167
+ import { z } from 'zod/v4';
1168
+ import type { Route } from './+types/create-user';
1169
+
1170
+ export const route: RoutePath<"/create-user"> = "/create-user";
1171
+
1172
+ export const formSchema = z.object({
1173
+ name: z.string().min(1),
1174
+ email: z.string().email(),
1175
+ });
1176
+
1177
+ interface ValidationError {
1178
+ field: string;
1179
+ message: string;
1180
+ }
1181
+
1182
+ export async function action({ request }: Route.ActionArgs): Promise<MaybeError<User, ValidationError[]>> {
1183
+ const formData = await request.formData();
1184
+ const name = formData.get("name") as string;
1185
+ const email = formData.get("email") as string;
1186
+
1187
+ // Validation
1188
+ const errors: ValidationError[] = [];
1189
+ if (!name) errors.push({ field: "name", message: "Name is required" });
1190
+ if (!email) errors.push({ field: "email", message: "Email is required" });
1191
+ if (email && !email.includes("@")) errors.push({ field: "email", message: "Invalid email format" });
1192
+
1193
+ if (errors.length > 0) {
1194
+ return fail(errors);
1195
+ }
1196
+
1197
+ try {
1198
+ const response = await fetch("/api/users", {
1199
+ method: "POST",
1200
+ headers: { "Content-Type": "application/json" },
1201
+ body: JSON.stringify({ name, email })
1202
+ });
1203
+
1204
+ if (!response.ok) {
1205
+ return fail([{ field: "general", message: "Failed to create user" }]);
1206
+ }
1207
+
1208
+ const user = await response.json();
1209
+ return success(user);
1210
+ } catch (error) {
1211
+ return fail([{ field: "general", message: "Network error occurred" }]);
1212
+ }
1213
+ }
1214
+
1215
+ export default function CreateUser() {
1216
+ const submitter = useDynamicSubmitter<typeof import("./create-user")>("/create-user");
1217
+
1218
+ return (
1219
+ <div>
1220
+ <h1>Create User</h1>
1221
+
1222
+ <submitter.Form method="post">
1223
+ <div>
1224
+ <label htmlFor="name">Name:</label>
1225
+ <input id="name" name="name" type="text" required />
1226
+ </div>
1227
+
1228
+ <div>
1229
+ <label htmlFor="email">Email:</label>
1230
+ <input id="email" name="email" type="email" required />
1231
+ </div>
1232
+
1233
+ <button type="submit" disabled={submitter.state === "submitting"}>
1234
+ {submitter.state === "submitting" ? "Creating..." : "Create User"}
1235
+ </button>
1236
+ </submitter.Form>
1237
+
1238
+ {submitter.data && (
1239
+ <div>
1240
+ {submitter.data.success ? (
1241
+ <div className="success">
1242
+ <h3>User Created!</h3>
1243
+ <p>Name: {submitter.data.result.name}</p>
1244
+ <p>Email: {submitter.data.result.email}</p>
1245
+ </div>
1246
+ ) : (
1247
+ <div className="errors">
1248
+ <h3>Validation Errors:</h3>
1249
+ <ul>
1250
+ {submitter.data.error.map((error, index) => (
1251
+ <li key={index}>
1252
+ <strong>{error.field}:</strong> {error.message}
1253
+ </li>
1254
+ ))}
1255
+ </ul>
1256
+ </div>
1257
+ )}
1258
+ </div>
344
1259
  )}
345
1260
  </div>
346
1261
  );
347
1262
  }
348
1263
  ```
349
1264
 
1265
+ ### MaybeError API Reference
1266
+
1267
+ ```tsx
1268
+ // Type definitions
1269
+ type MaybeError<T = undefined, TError = string> = DefiniteSuccess<T> | DefiniteError<TError>;
1270
+
1271
+ type DefiniteSuccess<T> = {
1272
+ success: true;
1273
+ result: T; // Optional if T is undefined
1274
+ };
1275
+
1276
+ type DefiniteError<TError> = {
1277
+ success: false;
1278
+ error: TError;
1279
+ };
1280
+
1281
+ // Utility functions
1282
+ const success = <T>(value: T): DefiniteSuccess<T> => ({ success: true, result: value });
1283
+ const fail = <TError>(error: TError): DefiniteError<TError> => ({ success: false, error });
1284
+
1285
+ // Type utility
1286
+ type AssumeSuccess<T extends MaybeError<unknown>> = /* extracts the success type */;
1287
+ ```
1288
+
1289
+ **Benefits:**
1290
+ - **Type Safety**: TypeScript enforces error handling at compile time
1291
+ - **Explicit Error Handling**: No more forgotten try-catch blocks
1292
+ - **Consistent API**: Same pattern across all operations that may fail
1293
+ - **Composable**: Easy to chain operations and handle errors at the right level
1294
+
1295
+ ## Troubleshooting
1296
+
1297
+ ### Common Issues
1298
+
1299
+ **❌ "Type 'string' is not assignable to type 'RoutePath<...>'"**
1300
+ ```tsx
1301
+ // ❌ Wrong - using string literal
1302
+ export const route = "/users";
1303
+
1304
+ // ✅ Correct - using RoutePath type
1305
+ export const route: RoutePath<"/users"> = "/users";
1306
+ ```
1307
+
1308
+ **❌ "Property 'data' does not exist on type 'any'"**
1309
+ ```tsx
1310
+ // ❌ Wrong - missing typeof import
1311
+ const fetcher = useDynamicFetcher("/users");
1312
+
1313
+ // ✅ Correct - with typeof import for type inference
1314
+ const fetcher = useDynamicFetcher<typeof import("./users")>("/users");
1315
+ ```
1316
+
1317
+ **❌ "Cannot find module './+types/route-name'"**
1318
+ - Make sure you're using React Router 7 in framework mode
1319
+ - Check that your `react-router.config.ts` is properly configured
1320
+ - The `+types` directory is auto-generated by React Router
1321
+
1322
+ **❌ "fetcher.data is always undefined"**
1323
+ ```tsx
1324
+ // ❌ Wrong - forgot to call load()
1325
+ const fetcher = useDynamicFetcher<typeof import("./users")>("/users");
1326
+
1327
+ // ✅ Correct - call load() to fetch data
1328
+ const fetcher = useDynamicFetcher<typeof import("./users")>("/users");
1329
+ useEffect(() => {
1330
+ fetcher.load();
1331
+ }, []);
1332
+ ```
1333
+
1334
+ ### Getting Help
1335
+
1336
+ - Check the [React Router 7 documentation](https://reactrouter.com) for framework mode setup
1337
+ - Look at the test application in the `tests/` directory for working examples
1338
+ - Open an issue on [GitHub](https://github.com/firtoz/router-toolkit) if you find a bug
1339
+
350
1340
  ## Contributing
351
1341
 
352
1342
  Contributions are welcome! Please feel free to submit a Pull Request.