@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 +1151 -161
- package/package.json +4 -1
- package/src/formAction.ts +188 -0
- package/src/index.ts +1 -0
package/README.md
CHANGED
|
@@ -35,196 +35,645 @@ 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';
|
|
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
|
-
|
|
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`
|
|
86
130
|
|
|
87
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|
|
140
292
|
|
|
141
|
-
function
|
|
142
|
-
const
|
|
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
|
-
|
|
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:**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
450
|
+
#### Using with useDynamicSubmitter
|
|
179
451
|
|
|
180
|
-
|
|
452
|
+
The `formAction` utility works seamlessly with `useDynamicSubmitter` when you export a `formSchema`:
|
|
181
453
|
|
|
182
454
|
```tsx
|
|
183
|
-
|
|
455
|
+
// app/routes/register.tsx (component)
|
|
456
|
+
import { useDynamicSubmitter } from "@firtoz/router-toolkit";
|
|
184
457
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
474
|
+
#### Error Handling
|
|
475
|
+
|
|
476
|
+
The `formAction` utility returns structured errors that you can handle in your components:
|
|
191
477
|
|
|
192
|
-
|
|
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
|
-
|
|
501
|
+
// Rest of component...
|
|
502
|
+
}
|
|
503
|
+
```
|
|
195
504
|
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
export const loader = async () => {
|
|
207
|
-
return { users: [] }; // Your data
|
|
208
|
-
};
|
|
548
|
+
**Complex Validation**
|
|
209
549
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
<
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
251
|
-
name: z.string().min(1
|
|
252
|
-
email: z.
|
|
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
|
|
257
|
-
const
|
|
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
|
-
<
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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="
|
|
271
|
-
|
|
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="
|
|
276
|
-
|
|
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
|
|
280
|
-
|
|
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
|
-
<
|
|
287
|
-
<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
|
-
###
|
|
889
|
+
### Combined Loader and Action (Full CRUD Pattern)
|
|
296
890
|
|
|
297
891
|
```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
|
-
});
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
<
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
{
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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.
|