@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 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,2 @@
1
+ // biome-ignore lint/suspicious/noExplicitAny: We really want to use any here
2
+ export type Func = (...args: any[]) => unknown;
@@ -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,3 @@
1
+ export type { Func } from "./Func";
2
+ export type { HrefArgs } from "./HrefArgs";
3
+ export type { RoutePath } from "./RoutePath";
@@ -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
+ };