@firtoz/router-toolkit 0.1.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 +307 -0
- package/package.json +68 -0
- package/src/index.ts +7 -0
- package/src/types/Func.ts +2 -0
- package/src/types/HrefArgs.ts +18 -0
- package/src/types/index.ts +3 -0
- package/src/useDynamicFetcher.ts +127 -0
- package/src/useDynamicSubmitter.tsx +74 -0
- package/src/useFetcherStateChanged.ts +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# @firtoz/router-toolkit
|
|
2
|
+
|
|
3
|
+
Type-safe React Router 7 framework mode helpers with enhanced fetching, form submission, and state management.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- ✅ **Type-safe routing** - Full TypeScript support with React Router 7 framework mode
|
|
8
|
+
- 🚀 **Enhanced fetching** - Dynamic fetchers with caching and query parameter support
|
|
9
|
+
- 📝 **Form submission** - Type-safe form handling with Zod validation
|
|
10
|
+
- 🔄 **State tracking** - Monitor fetcher state changes with ease
|
|
11
|
+
- 🎯 **Zero configuration** - Works out of the box with React Router 7
|
|
12
|
+
- 📦 **Tree-shakeable** - Import only what you need
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @firtoz/router-toolkit
|
|
18
|
+
# or
|
|
19
|
+
yarn add @firtoz/router-toolkit
|
|
20
|
+
# or
|
|
21
|
+
pnpm add @firtoz/router-toolkit
|
|
22
|
+
# or
|
|
23
|
+
bun add @firtoz/router-toolkit
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Peer Dependencies
|
|
27
|
+
|
|
28
|
+
This package requires the following peer dependencies:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
33
|
+
"react-router": "^7.0.0",
|
|
34
|
+
"zod": "^4.0.0"
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Hooks
|
|
39
|
+
|
|
40
|
+
### `useDynamicFetcher`
|
|
41
|
+
|
|
42
|
+
Enhanced version of React Router's `useFetcher` with type safety and additional features.
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { useDynamicFetcher } from '@firtoz/router-toolkit';
|
|
46
|
+
|
|
47
|
+
function MyComponent() {
|
|
48
|
+
const fetcher = useDynamicFetcher('/api/users');
|
|
49
|
+
|
|
50
|
+
const handleFetch = () => {
|
|
51
|
+
// Basic fetch
|
|
52
|
+
fetcher.load();
|
|
53
|
+
|
|
54
|
+
// Fetch with query parameters
|
|
55
|
+
fetcher.load({ page: '1', limit: '10' });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div>
|
|
60
|
+
{fetcher.state === 'loading' && <p>Loading...</p>}
|
|
61
|
+
{fetcher.data && <pre>{JSON.stringify(fetcher.data, null, 2)}</pre>}
|
|
62
|
+
<button onClick={handleFetch}>Fetch Data</button>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `useCachedFetch`
|
|
69
|
+
|
|
70
|
+
Regular fetch-based hook that avoids route invalidation and provides caching.
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { useCachedFetch } from '@firtoz/router-toolkit';
|
|
74
|
+
|
|
75
|
+
function CachedComponent() {
|
|
76
|
+
const { data, isLoading, error } = useCachedFetch('/api/static-data');
|
|
77
|
+
|
|
78
|
+
if (isLoading) return <div>Loading...</div>;
|
|
79
|
+
if (error) return <div>Error: {error.message}</div>;
|
|
80
|
+
|
|
81
|
+
return <div>{JSON.stringify(data)}</div>;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### `useDynamicSubmitter`
|
|
86
|
+
|
|
87
|
+
Type-safe form submission with Zod validation and enhanced submit functionality.
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import { useDynamicSubmitter } from '@firtoz/router-toolkit';
|
|
91
|
+
import { z } from 'zod/v4';
|
|
92
|
+
|
|
93
|
+
const formSchema = z.object({
|
|
94
|
+
name: z.string(),
|
|
95
|
+
email: z.string().email(),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function ContactForm() {
|
|
99
|
+
const submitter = useDynamicSubmitter('/api/contact');
|
|
100
|
+
|
|
101
|
+
const handleSubmit = (formData: z.infer<typeof formSchema>) => {
|
|
102
|
+
submitter.submit(formData, { method: 'POST' });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<submitter.Form method="POST">
|
|
107
|
+
<input name="name" type="text" />
|
|
108
|
+
<input name="email" type="email" />
|
|
109
|
+
<button type="submit">Submit</button>
|
|
110
|
+
</submitter.Form>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `useFetcherStateChanged`
|
|
116
|
+
|
|
117
|
+
Track changes in fetcher state and react to them.
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { useFetcher } from 'react-router';
|
|
121
|
+
import { useFetcherStateChanged } from '@firtoz/router-toolkit';
|
|
122
|
+
|
|
123
|
+
function StateTracker() {
|
|
124
|
+
const fetcher = useFetcher();
|
|
125
|
+
|
|
126
|
+
useFetcherStateChanged(fetcher, (lastState, newState) => {
|
|
127
|
+
console.log(`State changed from ${lastState} to ${newState}`);
|
|
128
|
+
|
|
129
|
+
if (newState === 'idle' && lastState === 'submitting') {
|
|
130
|
+
// Handle successful submission
|
|
131
|
+
console.log('Form submitted successfully!');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<fetcher.Form method="POST" action="/api/submit">
|
|
137
|
+
<button type="submit">Submit</button>
|
|
138
|
+
<p>Current state: {fetcher.state}</p>
|
|
139
|
+
</fetcher.Form>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Type Helpers
|
|
145
|
+
|
|
146
|
+
### `Func`
|
|
147
|
+
|
|
148
|
+
Generic function type helper for route loaders and actions.
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import type { Func } from '@firtoz/router-toolkit/types';
|
|
152
|
+
|
|
153
|
+
// Usage in route modules
|
|
154
|
+
type RouteModule = {
|
|
155
|
+
file: keyof Register["pages"];
|
|
156
|
+
loader: Func;
|
|
157
|
+
};
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### `HrefArgs`
|
|
161
|
+
|
|
162
|
+
Type helper for extracting href arguments from route paths.
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
import type { HrefArgs } from '@firtoz/router-toolkit/types';
|
|
166
|
+
|
|
167
|
+
// Usage for type-safe routing
|
|
168
|
+
type ProfileArgs = HrefArgs<'/profile/:id'>;
|
|
169
|
+
// ProfileArgs is [{ id: string }]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Usage with React Router 7 Framework Mode
|
|
173
|
+
|
|
174
|
+
This toolkit is specifically designed for React Router 7's framework mode. Make sure your routes are properly typed in your `react-router.config.ts`:
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
// react-router.config.ts
|
|
178
|
+
import type { Config } from '@react-router/dev/config';
|
|
179
|
+
|
|
180
|
+
export default {
|
|
181
|
+
// Your config
|
|
182
|
+
} satisfies Config;
|
|
183
|
+
|
|
184
|
+
// This will generate the Register types that the toolkit relies on
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Examples
|
|
188
|
+
|
|
189
|
+
### Complete Form with Validation
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
import { useDynamicSubmitter } from '@firtoz/router-toolkit';
|
|
193
|
+
import { z } from 'zod/v4';
|
|
194
|
+
|
|
195
|
+
const userSchema = z.object({
|
|
196
|
+
name: z.string().min(1, 'Name is required'),
|
|
197
|
+
email: z.string().email('Invalid email'),
|
|
198
|
+
age: z.number().min(18, 'Must be 18 or older'),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
function UserForm() {
|
|
202
|
+
const submitter = useDynamicSubmitter('/api/users');
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div>
|
|
206
|
+
<h2>Create User</h2>
|
|
207
|
+
|
|
208
|
+
<submitter.Form method="POST">
|
|
209
|
+
<div>
|
|
210
|
+
<label htmlFor="name">Name:</label>
|
|
211
|
+
<input name="name" type="text" required />
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div>
|
|
215
|
+
<label htmlFor="email">Email:</label>
|
|
216
|
+
<input name="email" type="email" required />
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div>
|
|
220
|
+
<label htmlFor="age">Age:</label>
|
|
221
|
+
<input name="age" type="number" required />
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<button type="submit" disabled={submitter.state === 'submitting'}>
|
|
225
|
+
{submitter.state === 'submitting' ? 'Creating...' : 'Create User'}
|
|
226
|
+
</button>
|
|
227
|
+
</submitter.Form>
|
|
228
|
+
|
|
229
|
+
{submitter.data && (
|
|
230
|
+
<div>
|
|
231
|
+
<h3>Success!</h3>
|
|
232
|
+
<p>User created: {JSON.stringify(submitter.data)}</p>
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Data Fetching with Error Handling
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
import { useDynamicFetcher, useFetcherStateChanged } from '@firtoz/router-toolkit';
|
|
244
|
+
import { useEffect, useState } from 'react';
|
|
245
|
+
|
|
246
|
+
function UserList() {
|
|
247
|
+
const fetcher = useDynamicFetcher('/api/users');
|
|
248
|
+
const [error, setError] = useState<string | null>(null);
|
|
249
|
+
|
|
250
|
+
useFetcherStateChanged(fetcher, (lastState, newState) => {
|
|
251
|
+
if (newState === 'idle' && fetcher.data?.error) {
|
|
252
|
+
setError(fetcher.data.error);
|
|
253
|
+
} else if (newState === 'loading') {
|
|
254
|
+
setError(null);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
fetcher.load();
|
|
260
|
+
}, []);
|
|
261
|
+
|
|
262
|
+
const refetch = () => {
|
|
263
|
+
fetcher.load({ refresh: 'true' });
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div>
|
|
268
|
+
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
269
|
+
<h2>Users</h2>
|
|
270
|
+
<button onClick={refetch} disabled={fetcher.state === 'loading'}>
|
|
271
|
+
{fetcher.state === 'loading' ? 'Loading...' : 'Refresh'}
|
|
272
|
+
</button>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{error && (
|
|
276
|
+
<div style={{ color: 'red', padding: '10px', background: '#fee' }}>
|
|
277
|
+
Error: {error}
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
280
|
+
|
|
281
|
+
{fetcher.data?.users && (
|
|
282
|
+
<ul>
|
|
283
|
+
{fetcher.data.users.map((user: any) => (
|
|
284
|
+
<li key={user.id}>
|
|
285
|
+
{user.name} ({user.email})
|
|
286
|
+
</li>
|
|
287
|
+
))}
|
|
288
|
+
</ul>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Contributing
|
|
296
|
+
|
|
297
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
MIT © [Firtina Ozbalikchi](https://github.com/firtoz)
|
|
302
|
+
|
|
303
|
+
## Links
|
|
304
|
+
|
|
305
|
+
- [GitHub Repository](https://github.com/firtoz/router-toolkit)
|
|
306
|
+
- [NPM Package](https://npmjs.com/package/@firtoz/router-toolkit)
|
|
307
|
+
- [React Router Documentation](https://reactrouter.com)
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@firtoz/router-toolkit",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Type-safe React Router 7 framework mode helpers with enhanced fetching, form submission, and state management",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"module": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"import": "./src/index.ts",
|
|
12
|
+
"require": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./*": {
|
|
15
|
+
"types": "./src/*",
|
|
16
|
+
"import": "./src/*",
|
|
17
|
+
"require": "./src/*"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src/**/*",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "echo 'No build step - using TypeScript source directly'",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"lint": "eslint src",
|
|
28
|
+
"test": "echo 'No tests yet'"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"react-router",
|
|
32
|
+
"react-router-7",
|
|
33
|
+
"framework-mode",
|
|
34
|
+
"hooks",
|
|
35
|
+
"typescript",
|
|
36
|
+
"form-submission",
|
|
37
|
+
"fetching",
|
|
38
|
+
"type-safe"
|
|
39
|
+
],
|
|
40
|
+
"author": "Firtina Ozbalikchi <firtoz@github.com>",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"homepage": "https://github.com/firtoz/router-toolkit#readme",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "https://github.com/firtoz/router-toolkit.git"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/firtoz/router-toolkit/issues"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"react": "^19.1.0",
|
|
52
|
+
"react-router": "^7.6.3",
|
|
53
|
+
"zod": "^3.25.69"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/react": "^19.1.8",
|
|
57
|
+
"react": "^19.1.0",
|
|
58
|
+
"react-router": "^7.6.3",
|
|
59
|
+
"typescript": "^5.8.3",
|
|
60
|
+
"zod": "^3.25.74"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=18.0.0"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Hooks
|
|
2
|
+
export { useDynamicFetcher, useCachedFetch } from "./useDynamicFetcher";
|
|
3
|
+
export { useDynamicSubmitter } from "./useDynamicSubmitter";
|
|
4
|
+
export { useFetcherStateChanged } from "./useFetcherStateChanged";
|
|
5
|
+
|
|
6
|
+
// Types
|
|
7
|
+
export type { Func, HrefArgs, RoutePath } from "./types";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { href, Register } from "react-router";
|
|
2
|
+
|
|
3
|
+
type AnyParams = Record<string, string | undefined>;
|
|
4
|
+
type AnyPages = Record<string, {
|
|
5
|
+
params: AnyParams;
|
|
6
|
+
}>;
|
|
7
|
+
|
|
8
|
+
export type RegisterPages = Register extends {
|
|
9
|
+
pages: infer Registered extends AnyPages;
|
|
10
|
+
} ? Registered : AnyPages;
|
|
11
|
+
|
|
12
|
+
export type HrefArgs<T extends keyof RegisterPages> = Parameters<typeof href<T>> extends [
|
|
13
|
+
string,
|
|
14
|
+
...infer Rest,
|
|
15
|
+
]
|
|
16
|
+
? Rest
|
|
17
|
+
: [];export type RoutePath<TPath extends keyof RegisterPages> = TPath;
|
|
18
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { href, useFetcher, type useLoaderData } from "react-router";
|
|
3
|
+
import type { Func } from "./types/Func";
|
|
4
|
+
import type { HrefArgs, RegisterPages } from "./types/HrefArgs";
|
|
5
|
+
|
|
6
|
+
type RouteModule = {
|
|
7
|
+
file: keyof RegisterPages;
|
|
8
|
+
loader: Func;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const useDynamicFetcher = <TInfo extends RouteModule>(
|
|
12
|
+
path: TInfo["file"],
|
|
13
|
+
...args: TInfo["file"] extends "undefined" ? HrefArgs<"/"> : HrefArgs<TInfo["file"]>
|
|
14
|
+
): Omit<ReturnType<typeof useFetcher<TInfo["loader"]>>, "load" | "submit"> & {
|
|
15
|
+
load: (queryParams?: Record<string, string>) => Promise<void>;
|
|
16
|
+
} => {
|
|
17
|
+
const url = useMemo(() => {
|
|
18
|
+
return href<typeof path>(path, ...args);
|
|
19
|
+
}, [path, args]);
|
|
20
|
+
|
|
21
|
+
const fetcher = useFetcher<TInfo["loader"]>({
|
|
22
|
+
key: `fetcher-${url}`,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const load = useCallback(
|
|
26
|
+
(queryParams?: Record<string, string>) => {
|
|
27
|
+
if (!queryParams || Object.keys(queryParams).length === 0) {
|
|
28
|
+
return fetcher.load(url);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Build URL with query parameters
|
|
32
|
+
const urlObj = new URL(url, window.location.origin);
|
|
33
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
34
|
+
urlObj.searchParams.set(key, value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return fetcher.load(urlObj.pathname + urlObj.search);
|
|
38
|
+
},
|
|
39
|
+
[fetcher.load, url],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
...fetcher,
|
|
44
|
+
load,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Cache for the useCachedFetch hook (regular fetch, not useFetcher)
|
|
49
|
+
const fetchCache = new Map<string, unknown>();
|
|
50
|
+
|
|
51
|
+
// Hook that uses regular fetch instead of useFetcher to avoid route invalidation
|
|
52
|
+
export const useCachedFetch = <
|
|
53
|
+
TInfo extends {
|
|
54
|
+
file: string;
|
|
55
|
+
module: RouteModule;
|
|
56
|
+
},
|
|
57
|
+
>(
|
|
58
|
+
path: TInfo["file"] extends "undefined"
|
|
59
|
+
? "/"
|
|
60
|
+
: `/${TInfo["file"]}` extends keyof RegisterPages
|
|
61
|
+
? `/${TInfo["file"]}`
|
|
62
|
+
: never,
|
|
63
|
+
...args: TInfo["file"] extends "undefined"
|
|
64
|
+
? HrefArgs<"/">
|
|
65
|
+
: `/${TInfo["file"]}` extends keyof RegisterPages
|
|
66
|
+
? HrefArgs<`/${TInfo["file"]}`>
|
|
67
|
+
: never
|
|
68
|
+
): {
|
|
69
|
+
data: ReturnType<typeof useLoaderData<TInfo["module"]["loader"]>> | undefined;
|
|
70
|
+
isLoading: boolean;
|
|
71
|
+
error: Error | undefined;
|
|
72
|
+
} => {
|
|
73
|
+
// Generate URL using href, same as useDynamicFetcher
|
|
74
|
+
const url = useMemo(() => {
|
|
75
|
+
// biome-ignore lint/suspicious/noExplicitAny: Complex conditional typing prevents TypeScript from inferring args when spreading
|
|
76
|
+
return href<typeof path>(path, ...(args as any));
|
|
77
|
+
}, [path, args]);
|
|
78
|
+
|
|
79
|
+
// Use the generated URL as the cache key
|
|
80
|
+
const cacheKey = url;
|
|
81
|
+
|
|
82
|
+
// Local state
|
|
83
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
84
|
+
const [error, setError] = useState<Error | undefined>(undefined);
|
|
85
|
+
const [data, setData] = useState<
|
|
86
|
+
ReturnType<typeof useLoaderData<TInfo["module"]["loader"]>> | undefined
|
|
87
|
+
>(() =>
|
|
88
|
+
fetchCache.has(cacheKey)
|
|
89
|
+
? (fetchCache.get(cacheKey) as ReturnType<typeof useLoaderData<TInfo["module"]["loader"]>>)
|
|
90
|
+
: undefined,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Auto-fetch on mount or when URL changes, if not in cache
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const fetchData = async () => {
|
|
96
|
+
// Skip fetch if data is already cached
|
|
97
|
+
if (fetchCache.has(cacheKey)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setIsLoading(true);
|
|
102
|
+
setError(undefined);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(url);
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = await response.json();
|
|
112
|
+
|
|
113
|
+
// Update cache and state
|
|
114
|
+
fetchCache.set(cacheKey, result);
|
|
115
|
+
setData(result as ReturnType<typeof useLoaderData<TInfo["module"]["loader"]>>);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
118
|
+
} finally {
|
|
119
|
+
setIsLoading(false);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
fetchData();
|
|
124
|
+
}, [url, cacheKey]);
|
|
125
|
+
|
|
126
|
+
return { data, isLoading, error };
|
|
127
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
type FetcherFormProps,
|
|
4
|
+
href,
|
|
5
|
+
type SubmitOptions,
|
|
6
|
+
type SubmitTarget,
|
|
7
|
+
useFetcher,
|
|
8
|
+
} from "react-router";
|
|
9
|
+
import type { z } from "zod/v4";
|
|
10
|
+
import { HrefArgs, RegisterPages } from "./types/HrefArgs";
|
|
11
|
+
import { Func } from "./types";
|
|
12
|
+
|
|
13
|
+
type RouteModule = {
|
|
14
|
+
file: keyof RegisterPages;
|
|
15
|
+
action: Func;
|
|
16
|
+
formSchema: z.ZodTypeAny;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type SubmitFunc<TModule extends RouteModule> = (
|
|
20
|
+
target: z.infer<TModule["formSchema"]> & SubmitTarget,
|
|
21
|
+
options: Omit<SubmitOptions, "action" | "method" | "encType"> & {
|
|
22
|
+
method: Exclude<SubmitOptions["method"], "GET">;
|
|
23
|
+
},
|
|
24
|
+
) => Promise<void>;
|
|
25
|
+
|
|
26
|
+
type SubmitForm = (
|
|
27
|
+
props: Omit<FetcherFormProps & React.RefAttributes<HTMLFormElement>, "action" | "method"> & {
|
|
28
|
+
method: Exclude<SubmitOptions["method"], "GET">;
|
|
29
|
+
},
|
|
30
|
+
) => React.ReactElement;
|
|
31
|
+
|
|
32
|
+
export const useDynamicSubmitter = <TInfo extends RouteModule>(
|
|
33
|
+
path: TInfo["file"],
|
|
34
|
+
...args: TInfo["file"] extends "undefined" ? HrefArgs<"/"> : HrefArgs<TInfo["file"]>
|
|
35
|
+
): Omit<ReturnType<typeof useFetcher<TInfo["action"]>>, "load" | "submit" | "Form"> & {
|
|
36
|
+
submit: SubmitFunc<TInfo>;
|
|
37
|
+
Form: SubmitForm;
|
|
38
|
+
} => {
|
|
39
|
+
const url = useMemo(() => {
|
|
40
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are sure the args are correct
|
|
41
|
+
return href<typeof path>(path, ...(args as any));
|
|
42
|
+
}, [path, args]);
|
|
43
|
+
|
|
44
|
+
const fetcher = useFetcher<TInfo["action"]>({
|
|
45
|
+
key: `submitter-${url}`,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const submit: SubmitFunc<TInfo> = useCallback(
|
|
49
|
+
(target, options) => {
|
|
50
|
+
// console.log("Submitting form to", url, target, options);
|
|
51
|
+
return fetcher.submit(target, {
|
|
52
|
+
...options,
|
|
53
|
+
action: url,
|
|
54
|
+
encType: "multipart/form-data",
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
[fetcher.submit, url],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const OriginalForm = fetcher.Form;
|
|
61
|
+
|
|
62
|
+
const Form: SubmitForm = useCallback(
|
|
63
|
+
(props) => {
|
|
64
|
+
return <OriginalForm action={url} {...props} />;
|
|
65
|
+
},
|
|
66
|
+
[url, OriginalForm],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
...fetcher,
|
|
71
|
+
submit,
|
|
72
|
+
Form,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { useFetcher } from "react-router";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A hook that tracks changes in a fetcher's state and calls a callback when it changes.
|
|
6
|
+
* @param fetcher The fetcher instance to track
|
|
7
|
+
* @param onChange Callback that receives the previous state and new state when the state changes
|
|
8
|
+
*/
|
|
9
|
+
export const useFetcherStateChanged = (
|
|
10
|
+
fetcher: Pick<ReturnType<typeof useFetcher>, "state">,
|
|
11
|
+
onChange: (lastState: typeof fetcher.state | undefined, newState: typeof fetcher.state) => void,
|
|
12
|
+
) => {
|
|
13
|
+
const lastStateRef = useRef<typeof fetcher.state>(fetcher.state);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (lastStateRef.current !== fetcher.state) {
|
|
17
|
+
onChange(lastStateRef.current, fetcher.state);
|
|
18
|
+
lastStateRef.current = fetcher.state;
|
|
19
|
+
}
|
|
20
|
+
}, [fetcher.state, onChange]);
|
|
21
|
+
};
|