@firtoz/router-toolkit 1.2.0 → 1.3.1

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.
Files changed (2) hide show
  1. package/README.md +932 -164
  2. package/package.json +4 -4
package/README.md CHANGED
@@ -35,196 +35,423 @@ 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';
69
76
 
70
- Regular fetch-based hook that avoids route invalidation and provides caching.
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
+ ```
95
+
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`
130
+
131
+ > **💡 Tip**: Start with `useDynamicFetcher` for data loading, then add `useDynamicSubmitter` for forms. The `useFetcherStateChanged` hook is great for notifications and side effects.
132
+
133
+ ## Main Hooks
134
+
135
+ ### `useDynamicFetcher`
86
136
 
87
- Type-safe form submission with Zod validation and enhanced submit functionality.
137
+ Enhanced version of React Router's `useFetcher` with type safety and query parameter support.
88
138
 
89
- **Basic Usage Pattern:**
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
+ });
292
+
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;
140
297
 
141
- function StateTracker() {
142
- const fetcher = useFetcher();
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:**
163
382
 
164
- ### `Func`
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
165
388
 
166
- Generic function type helper for route loaders and actions.
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
167
394
 
168
- ```tsx
169
- import type { Func } from '@firtoz/router-toolkit/types';
170
-
171
- // Usage in route modules
172
- type RouteModule = {
173
- file: keyof Register["pages"];
174
- loader: Func;
175
- };
176
- ```
395
+ ## Type Utilities
177
396
 
178
- ### `HrefArgs`
397
+ ### `RoutePath<T>`
179
398
 
180
- Type helper for extracting href arguments from route paths.
399
+ Type-safe route path helper that ensures you're using valid route paths from your React Router configuration.
181
400
 
182
401
  ```tsx
183
- import type { HrefArgs } from '@firtoz/router-toolkit/types';
402
+ import type { RoutePath } from '@firtoz/router-toolkit';
403
+
404
+ // Ensures "/users" is a valid route in your app
405
+ export const route: RoutePath<"/users"> = "/users";
184
406
 
185
- // Usage for type-safe routing
186
- type ProfileArgs = HrefArgs<'/profile/:id'>;
187
- // ProfileArgs is [{ id: string }]
407
+ // TypeScript error if route doesn't exist
408
+ export const invalidRoute: RoutePath<"/non-existent"> = "/non-existent"; // ❌ Error
188
409
  ```
189
410
 
190
- ## Usage with React Router 7 Framework Mode
411
+ 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.
191
412
 
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:
413
+ ## Additional Utilities
193
414
 
194
- ### Route Setup Pattern
415
+ ### `useCachedFetch`
195
416
 
196
- For each route file, follow this pattern to enable full type safety:
417
+ Alternative to `useDynamicFetcher` that uses standard `fetch()` instead of React Router's fetcher system. Provides automatic caching and avoids route invalidation.
197
418
 
198
419
  ```tsx
199
- // app/routes/users.tsx
200
- import { useDynamicFetcher, type RoutePath } from '@firtoz/router-toolkit';
420
+ // app/routes/config.tsx
421
+ import { useCachedFetch, type RoutePath } from '@firtoz/router-toolkit';
201
422
 
202
- // 1. Export your route constant with proper typing
203
- export const route: RoutePath<"users"> = "users";
423
+ export const route: RoutePath<"/config"> = "/config";
204
424
 
205
- // 2. Define your loader/action as usual
206
425
  export const loader = async () => {
207
- return { users: [] }; // Your data
426
+ return {
427
+ apiUrl: "https://api.example.com",
428
+ version: "1.0.0",
429
+ features: ["auth", "payments"]
430
+ };
208
431
  };
209
432
 
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");
213
-
214
- const handleRefresh = () => {
215
- fetcher.load(); // No need to specify URL - it's inferred
216
- };
433
+ export default function ConfigPage() {
434
+ const { data, isLoading, error } = useCachedFetch<typeof import("./config")>("/config");
217
435
 
436
+ if (isLoading) return <div>Loading...</div>;
437
+ if (error) return <div>Error: {error.message}</div>;
438
+
218
439
  return (
219
440
  <div>
220
- <button onClick={handleRefresh}>Refresh</button>
221
- {fetcher.data && <div>{JSON.stringify(fetcher.data)}</div>}
441
+ <h1>Configuration</h1>
442
+ <p>API: {data?.apiUrl}</p>
443
+ <p>Version: {data?.version}</p>
222
444
  </div>
223
445
  );
224
446
  }
225
447
  ```
226
448
 
227
- ### Configuration
449
+ **When to use `useCachedFetch` vs `useDynamicFetcher`:**
450
+
451
+ - **`useCachedFetch`**: Static data, configuration, content that rarely changes
452
+ - **`useDynamicFetcher`**: Dynamic data, user-specific content, data that changes frequently
453
+
454
+ ## Configuration
228
455
 
229
456
  Make sure your routes are properly typed in your `react-router.config.ts`:
230
457
 
@@ -239,52 +466,197 @@ export default {
239
466
  // This will generate the Register types that the toolkit relies on
240
467
  ```
241
468
 
242
- ## Examples
469
+ ## Real-World Examples
470
+
471
+ 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.
472
+
473
+ > **🚀 Quick Copy**: Each example below is a complete, working route file. Copy the entire code block to get started immediately.
474
+
475
+ ### Data Loading with Refresh (Loader Test Pattern)
476
+
477
+ ```tsx
478
+ // app/routes/loader-test.tsx
479
+ import { useDynamicFetcher, type RoutePath } from '@firtoz/router-toolkit';
480
+
481
+ interface LoaderData {
482
+ user: {
483
+ id: number;
484
+ name: string;
485
+ email: string;
486
+ };
487
+ timestamp: string;
488
+ }
489
+
490
+ export const route: RoutePath<"/loader-test"> = "/loader-test";
491
+
492
+ export const loader = async (): Promise<LoaderData> => {
493
+ // Simulate API call delay
494
+ await new Promise((resolve) => setTimeout(resolve, 500));
495
+
496
+ return {
497
+ user: {
498
+ id: 1,
499
+ name: "John Doe",
500
+ email: "john@example.com",
501
+ },
502
+ timestamp: new Date().toISOString(),
503
+ };
504
+ };
505
+
506
+ export default function LoaderTest() {
507
+ const fetcher = useDynamicFetcher<typeof import("./loader-test")>("/loader-test");
508
+
509
+ const handleRefresh = () => {
510
+ fetcher.load();
511
+ };
512
+
513
+ return (
514
+ <div className="p-6">
515
+ <h1 className="text-2xl font-bold mb-4">Loader Test</h1>
516
+ <p className="mb-4">Testing React Router useFetcher hook</p>
517
+
518
+ <button
519
+ type="button"
520
+ onClick={handleRefresh}
521
+ disabled={fetcher.state === "loading"}
522
+ className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
523
+ >
524
+ {fetcher.state === "loading" ? "Loading..." : "Refresh Data"}
525
+ </button>
526
+
527
+ <div className="mt-6">
528
+ <h2 className="text-lg font-semibold mb-2">Fetcher State:</h2>
529
+ <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
530
+ {JSON.stringify({ state: fetcher.state }, null, 2)}
531
+ </pre>
532
+ </div>
533
+
534
+ {fetcher.data && (
535
+ <div className="mt-6">
536
+ <h2 className="text-lg font-semibold mb-2">Fetched Data:</h2>
537
+ <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
538
+ {JSON.stringify(fetcher.data, null, 2)}
539
+ </pre>
540
+ </div>
541
+ )}
243
542
 
244
- ### Complete Form with Validation
543
+ {fetcher.state === "idle" && fetcher.data && (
544
+ <div className="mt-4 p-3 bg-green-100 rounded">
545
+ <p className="text-green-800">✅ Data loaded successfully!</p>
546
+ </div>
547
+ )}
548
+ </div>
549
+ );
550
+ }
551
+ ```
552
+
553
+ ### Form Submission (Action Test Pattern)
245
554
 
246
555
  ```tsx
247
- import { useDynamicSubmitter } from '@firtoz/router-toolkit';
556
+ // app/routes/action-test.tsx
557
+ import { useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';
248
558
  import { z } from 'zod/v4';
559
+ import type { Route } from './+types/action-test';
560
+
561
+ interface ActionData {
562
+ success: boolean;
563
+ message: string;
564
+ submittedData?: {
565
+ name: string;
566
+ email: string;
567
+ };
568
+ }
569
+
570
+ export const route: RoutePath<"/action-test"> = "/action-test";
249
571
 
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'),
572
+ export const formSchema = z.object({
573
+ name: z.string().min(1),
574
+ email: z.email(),
254
575
  });
255
576
 
256
- function UserForm() {
257
- const submitter = useDynamicSubmitter('/api/users');
577
+ export async function action({ request }: Route.ActionArgs): Promise<ActionData> {
578
+ const formData = await request.formData();
579
+ const name = formData.get("name") as string;
580
+ const email = formData.get("email") as string;
581
+
582
+ // Simulate processing delay
583
+ await new Promise((resolve) => setTimeout(resolve, 1000));
584
+
585
+ // Simple validation
586
+ if (!name || !email) {
587
+ return {
588
+ success: false,
589
+ message: "Name and email are required",
590
+ };
591
+ }
592
+
593
+ return {
594
+ success: true,
595
+ message: "Form submitted successfully!",
596
+ submittedData: { name, email },
597
+ };
598
+ }
599
+
600
+ export default function ActionTest() {
601
+ const submitter = useDynamicSubmitter<typeof import("./action-test")>("/action-test");
258
602
 
259
603
  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
-
604
+ <div className="p-6">
605
+ <h1 className="text-2xl font-bold mb-4">Action Test</h1>
606
+ <p className="mb-4">Testing React Router form actions</p>
607
+
608
+ <submitter.Form method="post" className="space-y-4 max-w-md">
269
609
  <div>
270
- <label htmlFor="email">Email:</label>
271
- <input name="email" type="email" required />
610
+ <label htmlFor="name" className="block text-sm font-medium mb-1">
611
+ Name:
612
+ </label>
613
+ <input
614
+ id="name"
615
+ name="name"
616
+ type="text"
617
+ required
618
+ className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
619
+ />
272
620
  </div>
273
-
621
+
274
622
  <div>
275
- <label htmlFor="age">Age:</label>
276
- <input name="age" type="number" required />
623
+ <label htmlFor="email" className="block text-sm font-medium mb-1">
624
+ Email:
625
+ </label>
626
+ <input
627
+ id="email"
628
+ name="email"
629
+ type="email"
630
+ required
631
+ className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
632
+ />
277
633
  </div>
278
-
279
- <button type="submit" disabled={submitter.state === 'submitting'}>
280
- {submitter.state === 'submitting' ? 'Creating...' : 'Create User'}
634
+
635
+ <button
636
+ type="submit"
637
+ disabled={submitter.state === "submitting"}
638
+ className="bg-green-500 text-white px-4 py-2 rounded disabled:opacity-50"
639
+ >
640
+ {submitter.state === "submitting" ? "Submitting..." : "Submit"}
281
641
  </button>
282
642
  </submitter.Form>
283
643
 
284
644
  {submitter.data && (
285
- <div>
286
- <h3>Success!</h3>
287
- <p>User created: {JSON.stringify(submitter.data)}</p>
645
+ <div className="mt-6">
646
+ <h2 className="text-lg font-semibold mb-2">Action Result:</h2>
647
+ <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
648
+ {JSON.stringify(submitter.data, null, 2)}
649
+ </pre>
650
+
651
+ {submitter.data.success ? (
652
+ <div className="mt-4 p-3 bg-green-100 rounded">
653
+ <p className="text-green-800">✅ {submitter.data.message}</p>
654
+ </div>
655
+ ) : (
656
+ <div className="mt-4 p-3 bg-red-100 rounded">
657
+ <p className="text-red-800">❌ {submitter.data.message}</p>
658
+ </div>
659
+ )}
288
660
  </div>
289
661
  )}
290
662
  </div>
@@ -292,61 +664,457 @@ function UserForm() {
292
664
  }
293
665
  ```
294
666
 
295
- ### Data Fetching with Error Handling
667
+ ### Combined Loader and Action (Full CRUD Pattern)
296
668
 
297
669
  ```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
- });
670
+ // app/routes/combined-test.tsx
671
+ import {
672
+ useDynamicFetcher,
673
+ useDynamicSubmitter,
674
+ type RoutePath,
675
+ } from '@firtoz/router-toolkit';
676
+ import { useLoaderData } from 'react-router';
677
+ import { z } from 'zod/v4';
678
+ import type { Route } from './+types/combined-test';
312
679
 
313
- useEffect(() => {
314
- fetcher.load();
315
- }, []);
680
+ interface User {
681
+ id: number;
682
+ name: string;
683
+ email: string;
684
+ lastUpdated: string;
685
+ }
686
+
687
+ interface LoaderData {
688
+ user: User;
689
+ }
690
+
691
+ type ActionData = {
692
+ success: boolean;
693
+ message: string;
694
+ updatedUser?: User;
695
+ };
696
+
697
+ export const route: RoutePath<"/combined-test"> = "/combined-test";
698
+
699
+ export const formSchema = z.object({
700
+ name: z.string().min(1),
701
+ email: z.email(),
702
+ });
703
+
704
+ export const loader = async (): Promise<LoaderData> => {
705
+ await new Promise((resolve) => setTimeout(resolve, 300));
316
706
 
317
- const refetch = () => {
318
- fetcher.load({ refresh: 'true' });
707
+ return {
708
+ user: {
709
+ id: 1,
710
+ name: "John Doe",
711
+ email: "john@example.com",
712
+ lastUpdated: new Date().toISOString(),
713
+ },
319
714
  };
715
+ };
716
+
717
+ export async function action({ request }: Route.ActionArgs): Promise<ActionData> {
718
+ const formData = await request.formData();
719
+ const name = formData.get("name") as string;
720
+ const email = formData.get("email") as string;
721
+
722
+ await new Promise((resolve) => setTimeout(resolve, 500));
723
+
724
+ if (!name || !email) {
725
+ return {
726
+ success: false,
727
+ message: "Name and email are required",
728
+ };
729
+ }
730
+
731
+ const updatedUser: User = {
732
+ id: 1,
733
+ name,
734
+ email,
735
+ lastUpdated: new Date().toISOString(),
736
+ };
737
+
738
+ return {
739
+ success: true,
740
+ message: "User updated successfully!",
741
+ updatedUser,
742
+ };
743
+ }
744
+
745
+ export default function CombinedTest() {
746
+ const loaderData = useLoaderData<LoaderData>();
747
+ const fetcher = useDynamicFetcher<typeof import("./combined-test")>("/combined-test");
748
+ const submitter = useDynamicSubmitter<typeof import("./combined-test")>("/combined-test");
320
749
 
321
750
  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>
751
+ <div className="p-6">
752
+ <h1 className="text-2xl font-bold mb-4">Combined Test</h1>
753
+ <p className="mb-4">Testing both loader data and form actions</p>
754
+
755
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
756
+ {/* Loader Data Section */}
757
+ <div>
758
+ <h2 className="text-lg font-semibold mb-3">Current User Data</h2>
759
+ <div className="bg-blue-50 p-4 rounded">
760
+ <h3 className="font-medium">Loaded from Server:</h3>
761
+ <pre className="mt-2 text-sm bg-gray-200 p-3 rounded text-gray-800">
762
+ {JSON.stringify(loaderData.user, null, 2)}
763
+ </pre>
764
+ </div>
765
+ </div>
766
+
767
+ {/* Action Form Section */}
768
+ <div>
769
+ <h2 className="text-lg font-semibold mb-3">Update User</h2>
770
+ <submitter.Form method="post" className="space-y-4">
771
+ <div>
772
+ <label htmlFor="name" className="block text-sm font-medium mb-1">
773
+ Name:
774
+ </label>
775
+ <input
776
+ id="name"
777
+ name="name"
778
+ type="text"
779
+ defaultValue={loaderData.user.name}
780
+ required
781
+ className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
782
+ />
783
+ </div>
784
+
785
+ <div>
786
+ <label htmlFor="email" className="block text-sm font-medium mb-1">
787
+ Email:
788
+ </label>
789
+ <input
790
+ id="email"
791
+ name="email"
792
+ type="email"
793
+ defaultValue={loaderData.user.email}
794
+ required
795
+ className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
796
+ />
797
+ </div>
798
+
799
+ <button
800
+ type="submit"
801
+ disabled={submitter.state === "submitting"}
802
+ className="bg-purple-500 text-white px-4 py-2 rounded disabled:opacity-50"
803
+ >
804
+ {submitter.state === "submitting" ? "Updating..." : "Update User"}
805
+ </button>
806
+ </submitter.Form>
807
+ </div>
808
+ </div>
809
+
810
+ {/* Status Section */}
811
+ <div className="mt-6">
812
+ <h2 className="text-lg font-semibold mb-2">Action Status:</h2>
813
+ <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
814
+ {JSON.stringify({ state: submitter.state }, null, 2)}
815
+ </pre>
328
816
  </div>
329
817
 
330
- {error && (
331
- <div style={{ color: 'red', padding: '10px', background: '#fee' }}>
332
- Error: {error}
818
+ {submitter.data && (
819
+ <div className="mt-6">
820
+ <h2 className="text-lg font-semibold mb-2">Action Result:</h2>
821
+ <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
822
+ {JSON.stringify(submitter.data, null, 2)}
823
+ </pre>
824
+
825
+ {submitter.data.success ? (
826
+ <div className="mt-4 p-3 bg-green-100 rounded">
827
+ <p className="text-green-800">✅ {submitter.data.message}</p>
828
+ {submitter.data.updatedUser && (
829
+ <p className="text-sm text-green-700 mt-1">
830
+ Tip: Reload the page to see if data persists (it won't in this demo)
831
+ </p>
832
+ )}
833
+ </div>
834
+ ) : (
835
+ <div className="mt-4 p-3 bg-red-100 rounded">
836
+ <p className="text-red-800">❌ {submitter.data.message}</p>
837
+ </div>
838
+ )}
333
839
  </div>
334
840
  )}
841
+ </div>
842
+ );
843
+ }
844
+ ```
335
845
 
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>
846
+ ## MaybeError Utility
847
+
848
+ 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.
849
+
850
+ ### Basic Usage
851
+
852
+ ```tsx
853
+ import { success, fail, type MaybeError } from '@firtoz/router-toolkit';
854
+
855
+ // Define a function that may fail
856
+ function divide(a: number, b: number): MaybeError<number> {
857
+ if (b === 0) {
858
+ return fail("Division by zero");
859
+ }
860
+ return success(a / b);
861
+ }
862
+
863
+ // Type-safe error handling
864
+ const result = divide(10, 2);
865
+ if (result.success) {
866
+ console.log(result.result); // 5 - TypeScript knows this is a number
867
+ } else {
868
+ console.error(result.error); // "Division by zero" - TypeScript knows this is a string
869
+ }
870
+ ```
871
+
872
+ ### Route Loader with Error Handling
873
+
874
+ ```tsx
875
+ // app/routes/user-profile.tsx
876
+ import { success, fail, type MaybeError, type RoutePath } from '@firtoz/router-toolkit';
877
+ import type { Route } from './+types/user-profile';
878
+
879
+ interface User {
880
+ id: string;
881
+ name: string;
882
+ email: string;
883
+ }
884
+
885
+ interface ApiError {
886
+ code: number;
887
+ message: string;
888
+ }
889
+
890
+ export const route: RoutePath<"/user-profile/:id"> = "/user-profile/:id";
891
+
892
+ // Loader that returns MaybeError for type-safe error handling
893
+ export const loader = async ({ params }: Route.LoaderArgs): Promise<MaybeError<User, ApiError>> => {
894
+ try {
895
+ const response = await fetch(`/api/users/${params.id}`);
896
+
897
+ if (!response.ok) {
898
+ return fail({
899
+ code: response.status,
900
+ message: response.status === 404 ? "User not found" : "Failed to fetch user"
901
+ });
902
+ }
903
+
904
+ const user = await response.json();
905
+ return success(user);
906
+ } catch (error) {
907
+ return fail({
908
+ code: 500,
909
+ message: "Network error occurred"
910
+ });
911
+ }
912
+ };
913
+
914
+ export default function UserProfile() {
915
+ const fetcher = useDynamicFetcher<typeof import("./user-profile")>("/user-profile/:id", { id: "123" });
916
+
917
+ // Handle the MaybeError result
918
+ if (!fetcher.data) {
919
+ return <div>Loading...</div>;
920
+ }
921
+
922
+ if (!fetcher.data.success) {
923
+ return (
924
+ <div className="error">
925
+ <h2>Error {fetcher.data.error.code}</h2>
926
+ <p>{fetcher.data.error.message}</p>
927
+ </div>
928
+ );
929
+ }
930
+
931
+ return (
932
+ <div>
933
+ <h1>{fetcher.data.result.name}</h1>
934
+ <p>Email: {fetcher.data.result.email}</p>
935
+ </div>
936
+ );
937
+ }
938
+ ```
939
+
940
+ ### Action with Error Handling
941
+
942
+ ```tsx
943
+ // app/routes/create-user.tsx
944
+ import { success, fail, type MaybeError, useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';
945
+ import { z } from 'zod/v4';
946
+ import type { Route } from './+types/create-user';
947
+
948
+ export const route: RoutePath<"/create-user"> = "/create-user";
949
+
950
+ export const formSchema = z.object({
951
+ name: z.string().min(1),
952
+ email: z.string().email(),
953
+ });
954
+
955
+ interface ValidationError {
956
+ field: string;
957
+ message: string;
958
+ }
959
+
960
+ export async function action({ request }: Route.ActionArgs): Promise<MaybeError<User, ValidationError[]>> {
961
+ const formData = await request.formData();
962
+ const name = formData.get("name") as string;
963
+ const email = formData.get("email") as string;
964
+
965
+ // Validation
966
+ const errors: ValidationError[] = [];
967
+ if (!name) errors.push({ field: "name", message: "Name is required" });
968
+ if (!email) errors.push({ field: "email", message: "Email is required" });
969
+ if (email && !email.includes("@")) errors.push({ field: "email", message: "Invalid email format" });
970
+
971
+ if (errors.length > 0) {
972
+ return fail(errors);
973
+ }
974
+
975
+ try {
976
+ const response = await fetch("/api/users", {
977
+ method: "POST",
978
+ headers: { "Content-Type": "application/json" },
979
+ body: JSON.stringify({ name, email })
980
+ });
981
+
982
+ if (!response.ok) {
983
+ return fail([{ field: "general", message: "Failed to create user" }]);
984
+ }
985
+
986
+ const user = await response.json();
987
+ return success(user);
988
+ } catch (error) {
989
+ return fail([{ field: "general", message: "Network error occurred" }]);
990
+ }
991
+ }
992
+
993
+ export default function CreateUser() {
994
+ const submitter = useDynamicSubmitter<typeof import("./create-user")>("/create-user");
995
+
996
+ return (
997
+ <div>
998
+ <h1>Create User</h1>
999
+
1000
+ <submitter.Form method="post">
1001
+ <div>
1002
+ <label htmlFor="name">Name:</label>
1003
+ <input id="name" name="name" type="text" required />
1004
+ </div>
1005
+
1006
+ <div>
1007
+ <label htmlFor="email">Email:</label>
1008
+ <input id="email" name="email" type="email" required />
1009
+ </div>
1010
+
1011
+ <button type="submit" disabled={submitter.state === "submitting"}>
1012
+ {submitter.state === "submitting" ? "Creating..." : "Create User"}
1013
+ </button>
1014
+ </submitter.Form>
1015
+
1016
+ {submitter.data && (
1017
+ <div>
1018
+ {submitter.data.success ? (
1019
+ <div className="success">
1020
+ <h3>User Created!</h3>
1021
+ <p>Name: {submitter.data.result.name}</p>
1022
+ <p>Email: {submitter.data.result.email}</p>
1023
+ </div>
1024
+ ) : (
1025
+ <div className="errors">
1026
+ <h3>Validation Errors:</h3>
1027
+ <ul>
1028
+ {submitter.data.error.map((error, index) => (
1029
+ <li key={index}>
1030
+ <strong>{error.field}:</strong> {error.message}
1031
+ </li>
1032
+ ))}
1033
+ </ul>
1034
+ </div>
1035
+ )}
1036
+ </div>
344
1037
  )}
345
1038
  </div>
346
1039
  );
347
1040
  }
348
1041
  ```
349
1042
 
1043
+ ### MaybeError API Reference
1044
+
1045
+ ```tsx
1046
+ // Type definitions
1047
+ type MaybeError<T = undefined, TError = string> = DefiniteSuccess<T> | DefiniteError<TError>;
1048
+
1049
+ type DefiniteSuccess<T> = {
1050
+ success: true;
1051
+ result: T; // Optional if T is undefined
1052
+ };
1053
+
1054
+ type DefiniteError<TError> = {
1055
+ success: false;
1056
+ error: TError;
1057
+ };
1058
+
1059
+ // Utility functions
1060
+ const success = <T>(value: T): DefiniteSuccess<T> => ({ success: true, result: value });
1061
+ const fail = <TError>(error: TError): DefiniteError<TError> => ({ success: false, error });
1062
+
1063
+ // Type utility
1064
+ type AssumeSuccess<T extends MaybeError<unknown>> = /* extracts the success type */;
1065
+ ```
1066
+
1067
+ **Benefits:**
1068
+ - **Type Safety**: TypeScript enforces error handling at compile time
1069
+ - **Explicit Error Handling**: No more forgotten try-catch blocks
1070
+ - **Consistent API**: Same pattern across all operations that may fail
1071
+ - **Composable**: Easy to chain operations and handle errors at the right level
1072
+
1073
+ ## Troubleshooting
1074
+
1075
+ ### Common Issues
1076
+
1077
+ **❌ "Type 'string' is not assignable to type 'RoutePath<...>'"**
1078
+ ```tsx
1079
+ // ❌ Wrong - using string literal
1080
+ export const route = "/users";
1081
+
1082
+ // ✅ Correct - using RoutePath type
1083
+ export const route: RoutePath<"/users"> = "/users";
1084
+ ```
1085
+
1086
+ **❌ "Property 'data' does not exist on type 'any'"**
1087
+ ```tsx
1088
+ // ❌ Wrong - missing typeof import
1089
+ const fetcher = useDynamicFetcher("/users");
1090
+
1091
+ // ✅ Correct - with typeof import for type inference
1092
+ const fetcher = useDynamicFetcher<typeof import("./users")>("/users");
1093
+ ```
1094
+
1095
+ **❌ "Cannot find module './+types/route-name'"**
1096
+ - Make sure you're using React Router 7 in framework mode
1097
+ - Check that your `react-router.config.ts` is properly configured
1098
+ - The `+types` directory is auto-generated by React Router
1099
+
1100
+ **❌ "fetcher.data is always undefined"**
1101
+ ```tsx
1102
+ // ❌ Wrong - forgot to call load()
1103
+ const fetcher = useDynamicFetcher<typeof import("./users")>("/users");
1104
+
1105
+ // ✅ Correct - call load() to fetch data
1106
+ const fetcher = useDynamicFetcher<typeof import("./users")>("/users");
1107
+ useEffect(() => {
1108
+ fetcher.load();
1109
+ }, []);
1110
+ ```
1111
+
1112
+ ### Getting Help
1113
+
1114
+ - Check the [React Router 7 documentation](https://reactrouter.com) for framework mode setup
1115
+ - Look at the test application in the `tests/` directory for working examples
1116
+ - Open an issue on [GitHub](https://github.com/firtoz/router-toolkit) if you find a bug
1117
+
350
1118
  ## Contributing
351
1119
 
352
1120
  Contributions are welcome! Please feel free to submit a Pull Request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/router-toolkit",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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",
@@ -50,9 +50,9 @@
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@firtoz/maybe-error": "^1.2.3",
53
- "react": "^19.1.0",
54
- "react-router": "^7.7.1",
55
- "zod": "^4.0.9"
53
+ "react": "^19.1.1",
54
+ "react-router": "^7.8.0",
55
+ "zod": "^4.0.15"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=18.0.0"