@firtoz/router-toolkit 1.3.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.
- package/README.md +932 -164
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,196 +35,423 @@ This package requires the following peer dependencies:
|
|
|
35
35
|
}
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
##
|
|
38
|
+
## Quick Start
|
|
39
39
|
|
|
40
|
-
|
|
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
|
-
|
|
44
|
+
Every route file needs to export a `route` constant for type inference:
|
|
43
45
|
|
|
44
46
|
```tsx
|
|
45
|
-
|
|
47
|
+
// app/routes/users.tsx
|
|
48
|
+
import { useDynamicFetcher, type RoutePath } from '@firtoz/router-toolkit';
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
const fetcher = useDynamicFetcher('/api/users');
|
|
50
|
+
export const route: RoutePath<"/users"> = "/users";
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
// app/routes/create-user.tsx
|
|
100
|
+
import { useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';
|
|
74
101
|
|
|
75
|
-
|
|
76
|
-
const { data, isLoading, error } = useCachedFetch('/api/static-data');
|
|
102
|
+
export const route: RoutePath<"/create-user"> = "/create-user";
|
|
77
103
|
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
137
|
+
Enhanced version of React Router's `useFetcher` with type safety and query parameter support.
|
|
88
138
|
|
|
89
|
-
|
|
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.
|
|
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
|
|
206
|
+
export async function action({ request }: Route.ActionArgs) {
|
|
107
207
|
const formData = await request.formData();
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
226
|
+
// 4. Use the hook with typeof import for full type inference
|
|
113
227
|
export default function ContactForm() {
|
|
114
|
-
|
|
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
|
-
<
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
298
|
+
// Simulate processing
|
|
299
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
143
300
|
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
<
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
381
|
+
**Common Use Cases:**
|
|
163
382
|
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
### `
|
|
397
|
+
### `RoutePath<T>`
|
|
179
398
|
|
|
180
|
-
Type helper
|
|
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 {
|
|
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
|
-
//
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
+
## Additional Utilities
|
|
193
414
|
|
|
194
|
-
###
|
|
415
|
+
### `useCachedFetch`
|
|
195
416
|
|
|
196
|
-
|
|
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/
|
|
200
|
-
import {
|
|
420
|
+
// app/routes/config.tsx
|
|
421
|
+
import { useCachedFetch, type RoutePath } from '@firtoz/router-toolkit';
|
|
201
422
|
|
|
202
|
-
|
|
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 {
|
|
426
|
+
return {
|
|
427
|
+
apiUrl: "https://api.example.com",
|
|
428
|
+
version: "1.0.0",
|
|
429
|
+
features: ["auth", "payments"]
|
|
430
|
+
};
|
|
208
431
|
};
|
|
209
432
|
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
<
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
251
|
-
name: z.string().min(1
|
|
252
|
-
email: z.
|
|
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
|
|
257
|
-
const
|
|
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
|
-
<
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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="
|
|
271
|
-
|
|
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="
|
|
276
|
-
|
|
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
|
|
280
|
-
|
|
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
|
-
<
|
|
287
|
-
<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
|
-
###
|
|
667
|
+
### Combined Loader and Action (Full CRUD Pattern)
|
|
296
668
|
|
|
297
669
|
```tsx
|
|
298
|
-
|
|
299
|
-
import {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
<
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
{
|
|
331
|
-
<div
|
|
332
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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.3.
|
|
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",
|