usehookify 1.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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/cli/bin.js +192 -0
- package/dist/index.cjs +132 -0
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +102 -0
- package/dist/registry/index.d.ts +24 -0
- package/dist/registry/index.js +92 -0
- package/package.json +61 -0
- package/src/hooks/useDebounce.test.ts +49 -0
- package/src/hooks/useDebounce.ts +17 -0
- package/src/hooks/useFetch.test.ts +93 -0
- package/src/hooks/useFetch.ts +54 -0
- package/src/hooks/useLocalStorage.test.ts +91 -0
- package/src/hooks/useLocalStorage.ts +47 -0
- package/src/hooks/useToggle.test.ts +42 -0
- package/src/hooks/useToggle.ts +17 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vivek Kumar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# usehookify
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/usehookify)
|
|
4
|
+
[](https://github.com/vivekKumarSinghH/usehook-js/blob/main/LICENSE)
|
|
5
|
+
|
|
6
|
+
A small, dependency-free React hooks library you can install as an npm
|
|
7
|
+
package or copy directly into your project via a CLI — same maintained
|
|
8
|
+
source either way.
|
|
9
|
+
|
|
10
|
+
## Hooks
|
|
11
|
+
|
|
12
|
+
| Hook | Category | Description |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `useLocalStorage` | storage | Persist React state to the browser's localStorage, synced across re-renders. |
|
|
15
|
+
| `useFetch` | network | Fetch data from a URL with loading/error state and a manual refetch. |
|
|
16
|
+
| `useDebounce` | utility | Return a debounced version of a rapidly-changing value. |
|
|
17
|
+
| `useToggle` | state | Manage a boolean value with a toggle function and an explicit setter. |
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install usehookify
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { useLocalStorage } from 'usehookify'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Or copy it directly — same source, no dependency
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx usehookify add useDebounce
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { useDebounce } from './hooks/useDebounce'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Both paths ship the exact same, byte-identical source.
|
|
40
|
+
|
|
41
|
+
## CLI
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx usehookify list
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Documentation
|
|
48
|
+
|
|
49
|
+
Full API reference, parameters, and usage examples:
|
|
50
|
+
[github.com/vivekKumarSinghH/usehook-js](https://github.com/vivekKumarSinghH/usehook-js)
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT © Vivek Kumar
|
package/dist/cli/bin.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/bin.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/registry/hooks.ts
|
|
7
|
+
var hooks = [
|
|
8
|
+
{
|
|
9
|
+
id: "useLocalStorage",
|
|
10
|
+
name: "useLocalStorage",
|
|
11
|
+
description: "Persist React state to the browser's localStorage, synced across re-renders.",
|
|
12
|
+
category: "storage",
|
|
13
|
+
sourceFile: "useLocalStorage.ts",
|
|
14
|
+
params: [
|
|
15
|
+
{ name: "key", type: "string", description: "The localStorage key to read/write.", required: true },
|
|
16
|
+
{
|
|
17
|
+
name: "initialValue",
|
|
18
|
+
type: "T",
|
|
19
|
+
description: "Value used when nothing is stored yet, or on the server during SSR.",
|
|
20
|
+
required: true
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
returns: "[T, (value: T | ((prev: T) => T)) => void]",
|
|
24
|
+
examples: [
|
|
25
|
+
{
|
|
26
|
+
title: "Basic usage",
|
|
27
|
+
code: "const [name, setName] = useLocalStorage('name', 'Anonymous')\n\nsetName('Ada')\nsetName((prev) => prev.toUpperCase())"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "useFetch",
|
|
33
|
+
name: "useFetch",
|
|
34
|
+
description: "Fetch data from a URL with loading/error state and a manual refetch.",
|
|
35
|
+
category: "network",
|
|
36
|
+
sourceFile: "useFetch.ts",
|
|
37
|
+
params: [
|
|
38
|
+
{ name: "url", type: "string", description: "The URL to fetch.", required: true },
|
|
39
|
+
{
|
|
40
|
+
name: "options",
|
|
41
|
+
type: "RequestInit",
|
|
42
|
+
description: "Standard fetch options (headers, method, body, etc.).",
|
|
43
|
+
required: false
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
returns: "{ data: T | null, error: Error | null, loading: boolean, refetch: () => void }",
|
|
47
|
+
examples: [
|
|
48
|
+
{
|
|
49
|
+
title: "Basic usage",
|
|
50
|
+
code: "const { data, error, loading, refetch } = useFetch<Post[]>('/api/posts')\n\nif (loading) return <Spinner />\nif (error) return <ErrorBanner message={error.message} onRetry={refetch} />\nreturn <PostList posts={data} />"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "useDebounce",
|
|
56
|
+
name: "useDebounce",
|
|
57
|
+
description: "Return a debounced version of a rapidly-changing value.",
|
|
58
|
+
category: "utility",
|
|
59
|
+
sourceFile: "useDebounce.ts",
|
|
60
|
+
params: [
|
|
61
|
+
{ name: "value", type: "T", description: "The value to debounce.", required: true },
|
|
62
|
+
{ name: "delayMs", type: "number", description: "Delay in milliseconds.", required: true }
|
|
63
|
+
],
|
|
64
|
+
returns: "T",
|
|
65
|
+
examples: [
|
|
66
|
+
{
|
|
67
|
+
title: "Debounced search input",
|
|
68
|
+
code: "const [query, setQuery] = useState('')\nconst debouncedQuery = useDebounce(query, 300)\n\n// debouncedQuery only updates 300ms after the user stops typing"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "useToggle",
|
|
74
|
+
name: "useToggle",
|
|
75
|
+
description: "Manage a boolean value with a toggle function and an explicit setter.",
|
|
76
|
+
category: "state",
|
|
77
|
+
sourceFile: "useToggle.ts",
|
|
78
|
+
params: [
|
|
79
|
+
{
|
|
80
|
+
name: "initial",
|
|
81
|
+
type: "boolean",
|
|
82
|
+
description: "Starting value. Defaults to false.",
|
|
83
|
+
required: false
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
returns: "[boolean, () => void, (value: boolean) => void]",
|
|
87
|
+
examples: [
|
|
88
|
+
{
|
|
89
|
+
title: "Modal open/close state",
|
|
90
|
+
code: 'const [isOpen, toggleOpen, setOpen] = useToggle(false)\n\n// flip on button click:\ntoggleOpen()\n\n// force closed, e.g. from an "X" button \u2014 never toggles back open:\nsetOpen(false)'
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
// src/cli/commands/list.ts
|
|
97
|
+
function printHookList(hookList = hooks) {
|
|
98
|
+
if (hookList.length === 0) {
|
|
99
|
+
console.log("No hooks available yet.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const hook of hookList) {
|
|
103
|
+
console.log(`${hook.id} ${hook.name} ${hook.category} ${hook.description}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function registerListCommand(program2) {
|
|
107
|
+
program2.command("list").description("List every hook available in usehookify").action(() => printHookList());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/cli/commands/add.ts
|
|
111
|
+
import path2 from "path";
|
|
112
|
+
|
|
113
|
+
// src/infra/fileWriter.ts
|
|
114
|
+
import { existsSync, mkdirSync, copyFileSync } from "fs";
|
|
115
|
+
import { fileURLToPath } from "url";
|
|
116
|
+
import path from "path";
|
|
117
|
+
|
|
118
|
+
// src/cli/errors.ts
|
|
119
|
+
var InvalidHookIdError = class extends Error {
|
|
120
|
+
constructor(hookId, validIds) {
|
|
121
|
+
super(`Unknown hook "${hookId}". Valid hooks: ${validIds.join(", ")}`);
|
|
122
|
+
this.hookId = hookId;
|
|
123
|
+
this.validIds = validIds;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
var TargetExistsError = class extends Error {
|
|
127
|
+
constructor(path3) {
|
|
128
|
+
super(`"${path3}" already exists. Re-run with --force to overwrite.`);
|
|
129
|
+
this.path = path3;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// src/infra/fileWriter.ts
|
|
134
|
+
function getHooksSourceDir() {
|
|
135
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
136
|
+
return path.join(packageRoot, "src", "hooks");
|
|
137
|
+
}
|
|
138
|
+
function writeHookFile(sourceFile, targetDir, options = {}) {
|
|
139
|
+
const sourcePath = path.join(getHooksSourceDir(), sourceFile);
|
|
140
|
+
const destPath = path.join(targetDir, sourceFile);
|
|
141
|
+
if (existsSync(destPath) && !options.force) {
|
|
142
|
+
throw new TargetExistsError(destPath);
|
|
143
|
+
}
|
|
144
|
+
mkdirSync(targetDir, { recursive: true });
|
|
145
|
+
copyFileSync(sourcePath, destPath);
|
|
146
|
+
return destPath;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/cli/commands/add.ts
|
|
150
|
+
function resolveHook(hookId, hookList = hooks) {
|
|
151
|
+
const hook = hookList.find((h) => h.id === hookId);
|
|
152
|
+
if (!hook) {
|
|
153
|
+
throw new InvalidHookIdError(
|
|
154
|
+
hookId,
|
|
155
|
+
hookList.map((h) => h.id)
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return hook;
|
|
159
|
+
}
|
|
160
|
+
function runAdd(hookId, options) {
|
|
161
|
+
const hook = resolveHook(hookId);
|
|
162
|
+
const targetDir = options.path ?? "./hooks";
|
|
163
|
+
const writtenPath = writeHookFile(hook.sourceFile, targetDir, { force: options.force });
|
|
164
|
+
const relativeFromCwd = path2.relative(process.cwd(), path2.resolve(targetDir));
|
|
165
|
+
const normalizedDir = relativeFromCwd.split(path2.sep).join("/");
|
|
166
|
+
const importDir = normalizedDir.startsWith(".") ? normalizedDir : `./${normalizedDir}`;
|
|
167
|
+
const importLine = `import { ${hook.name} } from '${importDir}/${hookId}'`;
|
|
168
|
+
return { writtenPath, importLine };
|
|
169
|
+
}
|
|
170
|
+
function registerAddCommand(program2) {
|
|
171
|
+
program2.command("add <hookId>").description("Copy a hook's source file into your project").option("-p, --path <dir>", "Directory to copy the hook into", "./hooks").option("-f, --force", "Overwrite the target file if it already exists", false).action((hookId, options) => {
|
|
172
|
+
try {
|
|
173
|
+
const { writtenPath, importLine } = runAdd(hookId, options);
|
|
174
|
+
console.log(`Copied to ${writtenPath}`);
|
|
175
|
+
console.log(`Add this import: ${importLine}`);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err instanceof InvalidHookIdError || err instanceof TargetExistsError) {
|
|
178
|
+
console.error(`Error: ${err.message}`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/cli/bin.ts
|
|
188
|
+
var program = new Command();
|
|
189
|
+
program.name("usehookify").description("usehookify CLI \u2014 list and copy React hooks").version("1.0.0");
|
|
190
|
+
registerListCommand(program);
|
|
191
|
+
registerAddCommand(program);
|
|
192
|
+
program.parse(process.argv);
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
useDebounce: () => useDebounce,
|
|
24
|
+
useFetch: () => useFetch,
|
|
25
|
+
useLocalStorage: () => useLocalStorage,
|
|
26
|
+
useToggle: () => useToggle
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(src_exports);
|
|
29
|
+
|
|
30
|
+
// src/hooks/useLocalStorage.ts
|
|
31
|
+
var import_react = require("react");
|
|
32
|
+
function readValue(key, initialValue) {
|
|
33
|
+
if (typeof window === "undefined") return initialValue;
|
|
34
|
+
try {
|
|
35
|
+
const stored = window.localStorage.getItem(key);
|
|
36
|
+
return stored ? JSON.parse(stored) : initialValue;
|
|
37
|
+
} catch {
|
|
38
|
+
return initialValue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function writeValue(key, value) {
|
|
42
|
+
if (typeof window === "undefined") return;
|
|
43
|
+
try {
|
|
44
|
+
window.localStorage.setItem(key, JSON.stringify(value));
|
|
45
|
+
} catch {
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function useLocalStorage(key, initialValue) {
|
|
49
|
+
const [storedValue, setStoredValue] = (0, import_react.useState)(() => readValue(key, initialValue));
|
|
50
|
+
const setValue = (0, import_react.useCallback)(
|
|
51
|
+
(value) => {
|
|
52
|
+
setStoredValue((prev) => {
|
|
53
|
+
const next = value instanceof Function ? value(prev) : value;
|
|
54
|
+
writeValue(key, next);
|
|
55
|
+
return next;
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
[key]
|
|
59
|
+
);
|
|
60
|
+
return [storedValue, setValue];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/hooks/useFetch.ts
|
|
64
|
+
var import_react2 = require("react");
|
|
65
|
+
function useFetch(url, options) {
|
|
66
|
+
const [data, setData] = (0, import_react2.useState)(null);
|
|
67
|
+
const [error, setError] = (0, import_react2.useState)(null);
|
|
68
|
+
const [loading, setLoading] = (0, import_react2.useState)(true);
|
|
69
|
+
const [version, setVersion] = (0, import_react2.useState)(0);
|
|
70
|
+
const mountedRef = (0, import_react2.useRef)(true);
|
|
71
|
+
(0, import_react2.useEffect)(() => {
|
|
72
|
+
mountedRef.current = true;
|
|
73
|
+
return () => {
|
|
74
|
+
mountedRef.current = false;
|
|
75
|
+
};
|
|
76
|
+
}, []);
|
|
77
|
+
(0, import_react2.useEffect)(() => {
|
|
78
|
+
setLoading(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
fetch(url, options).then(async (response) => {
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error(`Request failed with status ${response.status}`);
|
|
83
|
+
}
|
|
84
|
+
return await response.json();
|
|
85
|
+
}).then((result) => {
|
|
86
|
+
if (!mountedRef.current) return;
|
|
87
|
+
setData(result);
|
|
88
|
+
setLoading(false);
|
|
89
|
+
}).catch((err) => {
|
|
90
|
+
if (!mountedRef.current) return;
|
|
91
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
92
|
+
setLoading(false);
|
|
93
|
+
});
|
|
94
|
+
}, [url, version]);
|
|
95
|
+
const refetch = (0, import_react2.useCallback)(() => {
|
|
96
|
+
setVersion((v) => v + 1);
|
|
97
|
+
}, []);
|
|
98
|
+
return { data, error, loading, refetch };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/hooks/useDebounce.ts
|
|
102
|
+
var import_react3 = require("react");
|
|
103
|
+
function useDebounce(value, delayMs) {
|
|
104
|
+
const [debouncedValue, setDebouncedValue] = (0, import_react3.useState)(value);
|
|
105
|
+
(0, import_react3.useEffect)(() => {
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
setDebouncedValue(value);
|
|
108
|
+
}, delayMs);
|
|
109
|
+
return () => clearTimeout(timer);
|
|
110
|
+
}, [value, delayMs]);
|
|
111
|
+
return debouncedValue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/hooks/useToggle.ts
|
|
115
|
+
var import_react4 = require("react");
|
|
116
|
+
function useToggle(initial = false) {
|
|
117
|
+
const [value, setValue] = (0, import_react4.useState)(initial);
|
|
118
|
+
const toggle = (0, import_react4.useCallback)(() => {
|
|
119
|
+
setValue((prev) => !prev);
|
|
120
|
+
}, []);
|
|
121
|
+
const setExplicit = (0, import_react4.useCallback)((next) => {
|
|
122
|
+
setValue(next);
|
|
123
|
+
}, []);
|
|
124
|
+
return [value, toggle, setExplicit];
|
|
125
|
+
}
|
|
126
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
127
|
+
0 && (module.exports = {
|
|
128
|
+
useDebounce,
|
|
129
|
+
useFetch,
|
|
130
|
+
useLocalStorage,
|
|
131
|
+
useToggle
|
|
132
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type SetValue<T> = T | ((prev: T) => T);
|
|
2
|
+
declare function useLocalStorage<T>(key: string, initialValue: T): [T, (value: SetValue<T>) => void];
|
|
3
|
+
|
|
4
|
+
interface UseFetchResult<T> {
|
|
5
|
+
data: T | null;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
refetch: () => void;
|
|
9
|
+
}
|
|
10
|
+
declare function useFetch<T>(url: string, options?: RequestInit): UseFetchResult<T>;
|
|
11
|
+
|
|
12
|
+
declare function useDebounce<T>(value: T, delayMs: number): T;
|
|
13
|
+
|
|
14
|
+
declare function useToggle(initial?: boolean): [boolean, () => void, (value: boolean) => void];
|
|
15
|
+
|
|
16
|
+
export { useDebounce, useFetch, useLocalStorage, useToggle };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type SetValue<T> = T | ((prev: T) => T);
|
|
2
|
+
declare function useLocalStorage<T>(key: string, initialValue: T): [T, (value: SetValue<T>) => void];
|
|
3
|
+
|
|
4
|
+
interface UseFetchResult<T> {
|
|
5
|
+
data: T | null;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
refetch: () => void;
|
|
9
|
+
}
|
|
10
|
+
declare function useFetch<T>(url: string, options?: RequestInit): UseFetchResult<T>;
|
|
11
|
+
|
|
12
|
+
declare function useDebounce<T>(value: T, delayMs: number): T;
|
|
13
|
+
|
|
14
|
+
declare function useToggle(initial?: boolean): [boolean, () => void, (value: boolean) => void];
|
|
15
|
+
|
|
16
|
+
export { useDebounce, useFetch, useLocalStorage, useToggle };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/hooks/useLocalStorage.ts
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
function readValue(key, initialValue) {
|
|
4
|
+
if (typeof window === "undefined") return initialValue;
|
|
5
|
+
try {
|
|
6
|
+
const stored = window.localStorage.getItem(key);
|
|
7
|
+
return stored ? JSON.parse(stored) : initialValue;
|
|
8
|
+
} catch {
|
|
9
|
+
return initialValue;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function writeValue(key, value) {
|
|
13
|
+
if (typeof window === "undefined") return;
|
|
14
|
+
try {
|
|
15
|
+
window.localStorage.setItem(key, JSON.stringify(value));
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function useLocalStorage(key, initialValue) {
|
|
20
|
+
const [storedValue, setStoredValue] = useState(() => readValue(key, initialValue));
|
|
21
|
+
const setValue = useCallback(
|
|
22
|
+
(value) => {
|
|
23
|
+
setStoredValue((prev) => {
|
|
24
|
+
const next = value instanceof Function ? value(prev) : value;
|
|
25
|
+
writeValue(key, next);
|
|
26
|
+
return next;
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
[key]
|
|
30
|
+
);
|
|
31
|
+
return [storedValue, setValue];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/hooks/useFetch.ts
|
|
35
|
+
import { useCallback as useCallback2, useEffect, useRef, useState as useState2 } from "react";
|
|
36
|
+
function useFetch(url, options) {
|
|
37
|
+
const [data, setData] = useState2(null);
|
|
38
|
+
const [error, setError] = useState2(null);
|
|
39
|
+
const [loading, setLoading] = useState2(true);
|
|
40
|
+
const [version, setVersion] = useState2(0);
|
|
41
|
+
const mountedRef = useRef(true);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
mountedRef.current = true;
|
|
44
|
+
return () => {
|
|
45
|
+
mountedRef.current = false;
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
setLoading(true);
|
|
50
|
+
setError(null);
|
|
51
|
+
fetch(url, options).then(async (response) => {
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
throw new Error(`Request failed with status ${response.status}`);
|
|
54
|
+
}
|
|
55
|
+
return await response.json();
|
|
56
|
+
}).then((result) => {
|
|
57
|
+
if (!mountedRef.current) return;
|
|
58
|
+
setData(result);
|
|
59
|
+
setLoading(false);
|
|
60
|
+
}).catch((err) => {
|
|
61
|
+
if (!mountedRef.current) return;
|
|
62
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
63
|
+
setLoading(false);
|
|
64
|
+
});
|
|
65
|
+
}, [url, version]);
|
|
66
|
+
const refetch = useCallback2(() => {
|
|
67
|
+
setVersion((v) => v + 1);
|
|
68
|
+
}, []);
|
|
69
|
+
return { data, error, loading, refetch };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/hooks/useDebounce.ts
|
|
73
|
+
import { useEffect as useEffect2, useState as useState3 } from "react";
|
|
74
|
+
function useDebounce(value, delayMs) {
|
|
75
|
+
const [debouncedValue, setDebouncedValue] = useState3(value);
|
|
76
|
+
useEffect2(() => {
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
setDebouncedValue(value);
|
|
79
|
+
}, delayMs);
|
|
80
|
+
return () => clearTimeout(timer);
|
|
81
|
+
}, [value, delayMs]);
|
|
82
|
+
return debouncedValue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/hooks/useToggle.ts
|
|
86
|
+
import { useCallback as useCallback3, useState as useState4 } from "react";
|
|
87
|
+
function useToggle(initial = false) {
|
|
88
|
+
const [value, setValue] = useState4(initial);
|
|
89
|
+
const toggle = useCallback3(() => {
|
|
90
|
+
setValue((prev) => !prev);
|
|
91
|
+
}, []);
|
|
92
|
+
const setExplicit = useCallback3((next) => {
|
|
93
|
+
setValue(next);
|
|
94
|
+
}, []);
|
|
95
|
+
return [value, toggle, setExplicit];
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
useDebounce,
|
|
99
|
+
useFetch,
|
|
100
|
+
useLocalStorage,
|
|
101
|
+
useToggle
|
|
102
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface Param {
|
|
2
|
+
name: string;
|
|
3
|
+
type: string;
|
|
4
|
+
description: string;
|
|
5
|
+
required: boolean;
|
|
6
|
+
}
|
|
7
|
+
interface Example {
|
|
8
|
+
title: string;
|
|
9
|
+
code: string;
|
|
10
|
+
}
|
|
11
|
+
interface HookMetadata {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
category: string;
|
|
16
|
+
sourceFile: string;
|
|
17
|
+
params: Param[];
|
|
18
|
+
returns: string;
|
|
19
|
+
examples: Example[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
declare const hooks: HookMetadata[];
|
|
23
|
+
|
|
24
|
+
export { type Example, type HookMetadata, type Param, hooks };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/registry/hooks.ts
|
|
2
|
+
var hooks = [
|
|
3
|
+
{
|
|
4
|
+
id: "useLocalStorage",
|
|
5
|
+
name: "useLocalStorage",
|
|
6
|
+
description: "Persist React state to the browser's localStorage, synced across re-renders.",
|
|
7
|
+
category: "storage",
|
|
8
|
+
sourceFile: "useLocalStorage.ts",
|
|
9
|
+
params: [
|
|
10
|
+
{ name: "key", type: "string", description: "The localStorage key to read/write.", required: true },
|
|
11
|
+
{
|
|
12
|
+
name: "initialValue",
|
|
13
|
+
type: "T",
|
|
14
|
+
description: "Value used when nothing is stored yet, or on the server during SSR.",
|
|
15
|
+
required: true
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
returns: "[T, (value: T | ((prev: T) => T)) => void]",
|
|
19
|
+
examples: [
|
|
20
|
+
{
|
|
21
|
+
title: "Basic usage",
|
|
22
|
+
code: "const [name, setName] = useLocalStorage('name', 'Anonymous')\n\nsetName('Ada')\nsetName((prev) => prev.toUpperCase())"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "useFetch",
|
|
28
|
+
name: "useFetch",
|
|
29
|
+
description: "Fetch data from a URL with loading/error state and a manual refetch.",
|
|
30
|
+
category: "network",
|
|
31
|
+
sourceFile: "useFetch.ts",
|
|
32
|
+
params: [
|
|
33
|
+
{ name: "url", type: "string", description: "The URL to fetch.", required: true },
|
|
34
|
+
{
|
|
35
|
+
name: "options",
|
|
36
|
+
type: "RequestInit",
|
|
37
|
+
description: "Standard fetch options (headers, method, body, etc.).",
|
|
38
|
+
required: false
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
returns: "{ data: T | null, error: Error | null, loading: boolean, refetch: () => void }",
|
|
42
|
+
examples: [
|
|
43
|
+
{
|
|
44
|
+
title: "Basic usage",
|
|
45
|
+
code: "const { data, error, loading, refetch } = useFetch<Post[]>('/api/posts')\n\nif (loading) return <Spinner />\nif (error) return <ErrorBanner message={error.message} onRetry={refetch} />\nreturn <PostList posts={data} />"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "useDebounce",
|
|
51
|
+
name: "useDebounce",
|
|
52
|
+
description: "Return a debounced version of a rapidly-changing value.",
|
|
53
|
+
category: "utility",
|
|
54
|
+
sourceFile: "useDebounce.ts",
|
|
55
|
+
params: [
|
|
56
|
+
{ name: "value", type: "T", description: "The value to debounce.", required: true },
|
|
57
|
+
{ name: "delayMs", type: "number", description: "Delay in milliseconds.", required: true }
|
|
58
|
+
],
|
|
59
|
+
returns: "T",
|
|
60
|
+
examples: [
|
|
61
|
+
{
|
|
62
|
+
title: "Debounced search input",
|
|
63
|
+
code: "const [query, setQuery] = useState('')\nconst debouncedQuery = useDebounce(query, 300)\n\n// debouncedQuery only updates 300ms after the user stops typing"
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "useToggle",
|
|
69
|
+
name: "useToggle",
|
|
70
|
+
description: "Manage a boolean value with a toggle function and an explicit setter.",
|
|
71
|
+
category: "state",
|
|
72
|
+
sourceFile: "useToggle.ts",
|
|
73
|
+
params: [
|
|
74
|
+
{
|
|
75
|
+
name: "initial",
|
|
76
|
+
type: "boolean",
|
|
77
|
+
description: "Starting value. Defaults to false.",
|
|
78
|
+
required: false
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
returns: "[boolean, () => void, (value: boolean) => void]",
|
|
82
|
+
examples: [
|
|
83
|
+
{
|
|
84
|
+
title: "Modal open/close state",
|
|
85
|
+
code: 'const [isOpen, toggleOpen, setOpen] = useToggle(false)\n\n// flip on button click:\ntoggleOpen()\n\n// force closed, e.g. from an "X" button \u2014 never toggles back open:\nsetOpen(false)'
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
];
|
|
90
|
+
export {
|
|
91
|
+
hooks
|
|
92
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "usehookify",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A small, dependency-free React hooks library — install as a package or copy via CLI, same source either way.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Vivek Kumar",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/vivekKumarSinghH/usehook-js.git",
|
|
10
|
+
"directory": "packages/usehook-js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["react", "hooks", "react-hooks", "typescript", "cli"],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.cjs",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"import": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"require": {
|
|
24
|
+
"types": "./dist/index.d.cts",
|
|
25
|
+
"default": "./dist/index.cjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"./registry": {
|
|
29
|
+
"types": "./dist/registry/index.d.ts",
|
|
30
|
+
"default": "./dist/registry/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": ["dist", "src/hooks"],
|
|
34
|
+
"bin": {
|
|
35
|
+
"usehookify": "./dist/cli/bin.js"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsup",
|
|
39
|
+
"test": "vitest run --coverage",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"lint": "eslint src --max-warnings=0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": ">=16.8.0"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"commander": "^12.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"tsup": "^8.0.0",
|
|
51
|
+
"vitest": "^2.0.0",
|
|
52
|
+
"@vitest/coverage-v8": "^2.0.0",
|
|
53
|
+
"jsdom": "^25.0.0",
|
|
54
|
+
"@testing-library/react": "^16.0.0",
|
|
55
|
+
"react": "^18.0.0",
|
|
56
|
+
"react-dom": "^18.0.0",
|
|
57
|
+
"@types/react": "^18.0.0",
|
|
58
|
+
"@types/node": "^22.0.0",
|
|
59
|
+
"typescript": "^5.5.0"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { useDebounce } from './useDebounce'
|
|
4
|
+
|
|
5
|
+
describe('useDebounce', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.useRealTimers()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns the initial value immediately', () => {
|
|
11
|
+
const { result } = renderHook(() => useDebounce('a', 300))
|
|
12
|
+
expect(result.current).toBe('a')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns the latest value only after the delay elapses', () => {
|
|
16
|
+
vi.useFakeTimers()
|
|
17
|
+
const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
|
|
18
|
+
initialProps: { value: 'a' },
|
|
19
|
+
})
|
|
20
|
+
rerender({ value: 'b' })
|
|
21
|
+
act(() => vi.advanceTimersByTime(299))
|
|
22
|
+
expect(result.current).toBe('a') // not yet
|
|
23
|
+
act(() => vi.advanceTimersByTime(1))
|
|
24
|
+
expect(result.current).toBe('b')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('only keeps the last value when changed rapidly within the delay window', () => {
|
|
28
|
+
vi.useFakeTimers()
|
|
29
|
+
const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
|
|
30
|
+
initialProps: { value: 'a' },
|
|
31
|
+
})
|
|
32
|
+
rerender({ value: 'b' })
|
|
33
|
+
act(() => vi.advanceTimersByTime(150))
|
|
34
|
+
rerender({ value: 'c' }) // resets the timer before 'b' ever fires
|
|
35
|
+
act(() => vi.advanceTimersByTime(150))
|
|
36
|
+
expect(result.current).toBe('a') // still not settled — only 150ms since 'c'
|
|
37
|
+
act(() => vi.advanceTimersByTime(150))
|
|
38
|
+
expect(result.current).toBe('c') // 'b' was skipped entirely
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('does not update after unmounting mid-delay', () => {
|
|
42
|
+
vi.useFakeTimers()
|
|
43
|
+
const { unmount } = renderHook(({ value }) => useDebounce(value, 300), {
|
|
44
|
+
initialProps: { value: 'a' },
|
|
45
|
+
})
|
|
46
|
+
unmount()
|
|
47
|
+
expect(() => act(() => vi.advanceTimersByTime(300))).not.toThrow()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export function useDebounce<T>(value: T, delayMs: number): T {
|
|
6
|
+
const [debouncedValue, setDebouncedValue] = useState(value)
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const timer = setTimeout(() => {
|
|
10
|
+
setDebouncedValue(value)
|
|
11
|
+
}, delayMs)
|
|
12
|
+
|
|
13
|
+
return () => clearTimeout(timer)
|
|
14
|
+
}, [value, delayMs])
|
|
15
|
+
|
|
16
|
+
return debouncedValue
|
|
17
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { renderHook, waitFor, act } from '@testing-library/react'
|
|
3
|
+
import { useFetch } from './useFetch'
|
|
4
|
+
|
|
5
|
+
describe('useFetch', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.restoreAllMocks()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('starts in a loading state with no data or error', () => {
|
|
11
|
+
vi.stubGlobal(
|
|
12
|
+
'fetch',
|
|
13
|
+
vi.fn(() => new Promise(() => {})) // never resolves — hold in loading
|
|
14
|
+
)
|
|
15
|
+
const { result } = renderHook(() => useFetch('/api/thing'))
|
|
16
|
+
expect(result.current).toMatchObject({ data: null, error: null, loading: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns parsed data on a successful response', async () => {
|
|
20
|
+
vi.stubGlobal(
|
|
21
|
+
'fetch',
|
|
22
|
+
vi.fn(() =>
|
|
23
|
+
Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 1 }) } as Response)
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
const { result } = renderHook(() => useFetch<{ id: number }>('/api/thing'))
|
|
27
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
28
|
+
expect(result.current.data).toEqual({ id: 1 })
|
|
29
|
+
expect(result.current.error).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('surfaces a non-OK response as an error, never throwing', async () => {
|
|
33
|
+
vi.stubGlobal(
|
|
34
|
+
'fetch',
|
|
35
|
+
vi.fn(() => Promise.resolve({ ok: false, status: 404 } as Response))
|
|
36
|
+
)
|
|
37
|
+
const { result } = renderHook(() => useFetch('/api/missing'))
|
|
38
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
39
|
+
expect(result.current.data).toBeNull()
|
|
40
|
+
expect(result.current.error).toBeInstanceOf(Error)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('surfaces a rejected fetch as an error, never throwing', async () => {
|
|
44
|
+
vi.stubGlobal(
|
|
45
|
+
'fetch',
|
|
46
|
+
vi.fn(() => Promise.reject(new Error('network down')))
|
|
47
|
+
)
|
|
48
|
+
const { result } = renderHook(() => useFetch('/api/thing'))
|
|
49
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
50
|
+
expect(result.current.error?.message).toBe('network down')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('refetch() re-triggers the request and resets loading first', async () => {
|
|
54
|
+
const fetchMock = vi
|
|
55
|
+
.fn()
|
|
56
|
+
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ n: 1 }) } as Response)
|
|
57
|
+
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ n: 2 }) } as Response)
|
|
58
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
59
|
+
|
|
60
|
+
const { result } = renderHook(() => useFetch<{ n: number }>('/api/thing'))
|
|
61
|
+
await waitFor(() => expect(result.current.data).toEqual({ n: 1 }))
|
|
62
|
+
|
|
63
|
+
act(() => {
|
|
64
|
+
result.current.refetch()
|
|
65
|
+
})
|
|
66
|
+
// act() flushes the synchronous setLoading(true) before the mocked
|
|
67
|
+
// fetch's microtask resolves, so this is safe to assert immediately
|
|
68
|
+
// rather than via waitFor (which could miss a transient state that
|
|
69
|
+
// resolves within the same tick).
|
|
70
|
+
expect(result.current.loading).toBe(true)
|
|
71
|
+
await waitFor(() => expect(result.current.data).toEqual({ n: 2 }))
|
|
72
|
+
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('does not update state after unmounting mid-flight', async () => {
|
|
76
|
+
let resolveFetch: (value: Response) => void = () => {}
|
|
77
|
+
vi.stubGlobal(
|
|
78
|
+
'fetch',
|
|
79
|
+
vi.fn(
|
|
80
|
+
() =>
|
|
81
|
+
new Promise<Response>((resolve) => {
|
|
82
|
+
resolveFetch = resolve
|
|
83
|
+
})
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
const { unmount } = renderHook(() => useFetch('/api/thing'))
|
|
87
|
+
unmount()
|
|
88
|
+
resolveFetch({ ok: true, json: () => Promise.resolve({}) } as Response)
|
|
89
|
+
// If the hook updated state on an unmounted component, React would log
|
|
90
|
+
// an error/warning — asserting no throw here is the meaningful check.
|
|
91
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface UseFetchResult<T> {
|
|
6
|
+
data: T | null
|
|
7
|
+
error: Error | null
|
|
8
|
+
loading: boolean
|
|
9
|
+
refetch: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useFetch<T>(url: string, options?: RequestInit): UseFetchResult<T> {
|
|
13
|
+
const [data, setData] = useState<T | null>(null)
|
|
14
|
+
const [error, setError] = useState<Error | null>(null)
|
|
15
|
+
const [loading, setLoading] = useState(true)
|
|
16
|
+
const [version, setVersion] = useState(0)
|
|
17
|
+
const mountedRef = useRef(true)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
mountedRef.current = true
|
|
21
|
+
return () => {
|
|
22
|
+
mountedRef.current = false
|
|
23
|
+
}
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
setLoading(true)
|
|
28
|
+
setError(null)
|
|
29
|
+
|
|
30
|
+
fetch(url, options)
|
|
31
|
+
.then(async (response) => {
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new Error(`Request failed with status ${response.status}`)
|
|
34
|
+
}
|
|
35
|
+
return (await response.json()) as T
|
|
36
|
+
})
|
|
37
|
+
.then((result) => {
|
|
38
|
+
if (!mountedRef.current) return
|
|
39
|
+
setData(result)
|
|
40
|
+
setLoading(false)
|
|
41
|
+
})
|
|
42
|
+
.catch((err: unknown) => {
|
|
43
|
+
if (!mountedRef.current) return
|
|
44
|
+
setError(err instanceof Error ? err : new Error(String(err)))
|
|
45
|
+
setLoading(false)
|
|
46
|
+
})
|
|
47
|
+
}, [url, version])
|
|
48
|
+
|
|
49
|
+
const refetch = useCallback(() => {
|
|
50
|
+
setVersion((v) => v + 1)
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
return { data, error, loading, refetch }
|
|
54
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { useLocalStorage, readValue, writeValue } from './useLocalStorage'
|
|
4
|
+
|
|
5
|
+
describe('useLocalStorage', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
window.localStorage.clear()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns the initial value when nothing is stored yet', () => {
|
|
11
|
+
const { result } = renderHook(() => useLocalStorage('count', 0))
|
|
12
|
+
expect(result.current[0]).toBe(0)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('reads an existing value from localStorage on mount', () => {
|
|
16
|
+
window.localStorage.setItem('count', JSON.stringify(42))
|
|
17
|
+
const { result } = renderHook(() => useLocalStorage('count', 0))
|
|
18
|
+
expect(result.current[0]).toBe(42)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('falls back to the initial value when stored JSON is corrupted', () => {
|
|
22
|
+
window.localStorage.setItem('count', '{not valid json')
|
|
23
|
+
const { result } = renderHook(() => useLocalStorage('count', 0))
|
|
24
|
+
expect(result.current[0]).toBe(0)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('updates state and persists when called with a direct value', () => {
|
|
28
|
+
const { result } = renderHook(() => useLocalStorage('count', 0))
|
|
29
|
+
act(() => {
|
|
30
|
+
result.current[1](5)
|
|
31
|
+
})
|
|
32
|
+
expect(result.current[0]).toBe(5)
|
|
33
|
+
expect(window.localStorage.getItem('count')).toBe('5')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('updates state based on the previous value when called with an updater function', () => {
|
|
37
|
+
const { result } = renderHook(() => useLocalStorage('count', 0))
|
|
38
|
+
act(() => {
|
|
39
|
+
result.current[1]((prev) => prev + 1)
|
|
40
|
+
})
|
|
41
|
+
act(() => {
|
|
42
|
+
result.current[1]((prev) => prev + 1)
|
|
43
|
+
})
|
|
44
|
+
expect(result.current[0]).toBe(2)
|
|
45
|
+
expect(window.localStorage.getItem('count')).toBe('2')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('re-reads the persisted value on a fresh mount (round-trip)', () => {
|
|
49
|
+
const first = renderHook(() => useLocalStorage('count', 0))
|
|
50
|
+
act(() => {
|
|
51
|
+
first.result.current[1](7)
|
|
52
|
+
})
|
|
53
|
+
first.unmount()
|
|
54
|
+
const second = renderHook(() => useLocalStorage('count', 0))
|
|
55
|
+
expect(second.result.current[0]).toBe(7)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('keeps state correct even if persisting throws (e.g. quota exceeded)', () => {
|
|
59
|
+
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
60
|
+
throw new DOMException('QuotaExceededError')
|
|
61
|
+
})
|
|
62
|
+
const { result } = renderHook(() => useLocalStorage('count', 0))
|
|
63
|
+
act(() => {
|
|
64
|
+
result.current[1](9)
|
|
65
|
+
})
|
|
66
|
+
expect(result.current[0]).toBe(9)
|
|
67
|
+
setItemSpy.mockRestore()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("readValue (SSR guard, tested directly — avoids fighting jsdom's global window)", () => {
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
vi.unstubAllGlobals()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns the initial value when window is undefined', () => {
|
|
76
|
+
vi.stubGlobal('window', undefined)
|
|
77
|
+
expect(readValue('count', 7)).toBe(7)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('writeValue (SSR guard, tested directly)', () => {
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
vi.unstubAllGlobals()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('is a no-op when window is undefined — never throws', () => {
|
|
87
|
+
vi.stubGlobal('window', undefined)
|
|
88
|
+
expect(() => writeValue('count', 1)).not.toThrow()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export type SetValue<T> = T | ((prev: T) => T)
|
|
6
|
+
|
|
7
|
+
export function readValue<T>(key: string, initialValue: T): T {
|
|
8
|
+
if (typeof window === 'undefined') return initialValue // SSR guard
|
|
9
|
+
try {
|
|
10
|
+
const stored = window.localStorage.getItem(key)
|
|
11
|
+
return stored ? (JSON.parse(stored) as T) : initialValue
|
|
12
|
+
} catch {
|
|
13
|
+
return initialValue // corrupt/foreign data in that key must not crash the caller
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeValue<T>(key: string, value: T): void {
|
|
18
|
+
if (typeof window === 'undefined') return // SSR guard — no-op if ever called server-side
|
|
19
|
+
try {
|
|
20
|
+
window.localStorage.setItem(key, JSON.stringify(value))
|
|
21
|
+
} catch {
|
|
22
|
+
// Quota exceeded, storage disabled, or a serialization error — in-memory
|
|
23
|
+
// state already updated by the caller; only persistence silently fails,
|
|
24
|
+
// matching this hook's contract of never throwing into the caller's
|
|
25
|
+
// render/update path.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useLocalStorage<T>(
|
|
30
|
+
key: string,
|
|
31
|
+
initialValue: T
|
|
32
|
+
): [T, (value: SetValue<T>) => void] {
|
|
33
|
+
const [storedValue, setStoredValue] = useState<T>(() => readValue(key, initialValue))
|
|
34
|
+
|
|
35
|
+
const setValue = useCallback(
|
|
36
|
+
(value: SetValue<T>) => {
|
|
37
|
+
setStoredValue((prev) => {
|
|
38
|
+
const next = value instanceof Function ? value(prev) : value
|
|
39
|
+
writeValue(key, next)
|
|
40
|
+
return next
|
|
41
|
+
})
|
|
42
|
+
},
|
|
43
|
+
[key]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return [storedValue, setValue]
|
|
47
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import { useToggle } from './useToggle'
|
|
4
|
+
|
|
5
|
+
describe('useToggle', () => {
|
|
6
|
+
it('defaults to false when no initial value is given', () => {
|
|
7
|
+
const { result } = renderHook(() => useToggle())
|
|
8
|
+
expect(result.current[0]).toBe(false)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('starts at the given initial value', () => {
|
|
12
|
+
const { result } = renderHook(() => useToggle(true))
|
|
13
|
+
expect(result.current[0]).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('flips the value when toggle is called', () => {
|
|
17
|
+
const { result } = renderHook(() => useToggle(false))
|
|
18
|
+
act(() => result.current[1]())
|
|
19
|
+
expect(result.current[0]).toBe(true)
|
|
20
|
+
act(() => result.current[1]())
|
|
21
|
+
expect(result.current[0]).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('sets an explicit value regardless of the current value', () => {
|
|
25
|
+
const { result } = renderHook(() => useToggle(false))
|
|
26
|
+
act(() => result.current[2](true))
|
|
27
|
+
expect(result.current[0]).toBe(true)
|
|
28
|
+
act(() => result.current[2](true))
|
|
29
|
+
expect(result.current[0]).toBe(true)
|
|
30
|
+
act(() => result.current[2](false))
|
|
31
|
+
expect(result.current[0]).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('keeps toggle and setValue referentially stable across re-renders', () => {
|
|
35
|
+
const { result, rerender } = renderHook(() => useToggle())
|
|
36
|
+
const [, firstToggle, firstSetValue] = result.current
|
|
37
|
+
rerender()
|
|
38
|
+
const [, secondToggle, secondSetValue] = result.current
|
|
39
|
+
expect(secondToggle).toBe(firstToggle)
|
|
40
|
+
expect(secondSetValue).toBe(firstSetValue)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export function useToggle(initial = false): [boolean, () => void, (value: boolean) => void] {
|
|
6
|
+
const [value, setValue] = useState(initial)
|
|
7
|
+
|
|
8
|
+
const toggle = useCallback(() => {
|
|
9
|
+
setValue((prev) => !prev)
|
|
10
|
+
}, [])
|
|
11
|
+
|
|
12
|
+
const setExplicit = useCallback((next: boolean) => {
|
|
13
|
+
setValue(next)
|
|
14
|
+
}, [])
|
|
15
|
+
|
|
16
|
+
return [value, toggle, setExplicit]
|
|
17
|
+
}
|