use-good-hooks 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/README.md ADDED
@@ -0,0 +1,343 @@
1
+ # 🪝 use-good-hooks
2
+
3
+ ![React](https://img.shields.io/badge/React-19.0.0+-61DAFB?style=flat-square&logo=react&logoColor=white)
4
+ ![TypeScript](https://img.shields.io/badge/TypeScript-5.8+-3178C6?style=flat-square&logo=typescript&logoColor=white)
5
+ ![Vite](https://img.shields.io/badge/Vite-6.2.0+-646CFF?style=flat-square&logo=vite&logoColor=white)
6
+ ![License](https://img.shields.io/badge/License-MIT-green?style=flat-square)
7
+ [![Vitest](https://img.shields.io/badge/-Vitest-729B1B?style=flat-square&logo=vitest&logoColor=white)](https://vitest.dev/)
8
+
9
+ A collection of well-tested, performance-optimized React hooks for common web application patterns. These hooks help manage state, UI interactions, and browser features with clean, reusable abstractions.
10
+
11
+ ## 📦 Installation
12
+
13
+ ```bash
14
+ # Using npm
15
+ npm install use-good-hooks
16
+
17
+ # Using yarn
18
+ yarn add use-good-hooks
19
+
20
+ # Using pnpm
21
+ pnpm add use-good-hooks
22
+ ```
23
+
24
+ ## 🔌 Requirements
25
+
26
+ - React 19.0.0+
27
+ - Lodash 4.17.21+
28
+
29
+ ## 🧰 Available Hooks
30
+
31
+ ### `useDebounce`
32
+
33
+ Debounces value changes to prevent rapid updates. Useful for search inputs, form validation, and other scenarios where you want to delay state updates until after a user has stopped changing the input.
34
+
35
+ ```typescript
36
+ import { useDebounce } from 'use-good-hooks';
37
+
38
+ function SearchComponent() {
39
+ const [searchTerm, setSearchTerm] = useState('');
40
+ const debouncedSearchTerm = useDebounce(searchTerm, 500); // 500ms delay
41
+
42
+ // API call will only happen 500ms after the user stops typing
43
+ useEffect(() => {
44
+ if (debouncedSearchTerm) {
45
+ searchAPI(debouncedSearchTerm);
46
+ }
47
+ }, [debouncedSearchTerm]);
48
+
49
+ return (
50
+ <input
51
+ type="text"
52
+ value={searchTerm}
53
+ onChange={(e) => setSearchTerm(e.target.value)}
54
+ placeholder="Search..."
55
+ />
56
+ );
57
+ }
58
+ ```
59
+
60
+ #### Parameters
61
+
62
+ - `value`: The value to debounce
63
+ - `delay`: (Optional) The delay in milliseconds (default: 300ms)
64
+
65
+ #### Returns
66
+
67
+ - The debounced value
68
+
69
+ ### `useThrottle`
70
+
71
+ Limits the rate at which a value can update. Useful for scroll events, window resizing, and other high-frequency events.
72
+
73
+ ```typescript
74
+ import { useThrottle } from 'use-good-hooks';
75
+
76
+ function ScrollTracker() {
77
+ const [scrollY, setScrollY] = useState(0);
78
+ const throttledScrollY = useThrottle(scrollY, 200); // 200ms throttle
79
+
80
+ useEffect(() => {
81
+ const handleScroll = () => {
82
+ setScrollY(window.scrollY);
83
+ };
84
+
85
+ window.addEventListener('scroll', handleScroll);
86
+ return () => window.removeEventListener('scroll', handleScroll);
87
+ }, []);
88
+
89
+ return <div>Throttled scroll position: {throttledScrollY}px</div>;
90
+ }
91
+ ```
92
+
93
+ #### Parameters
94
+
95
+ - `value`: The value to throttle
96
+ - `delay`: (Optional) The throttle interval in milliseconds (default: 300ms)
97
+
98
+ #### Returns
99
+
100
+ - The throttled value
101
+
102
+ ### `usePrev`
103
+
104
+ Captures the previous value of a state or prop. Useful for comparing changes between renders.
105
+
106
+ ```typescript
107
+ import { usePrev } from 'use-good-hooks';
108
+
109
+ function Counter({ count }) {
110
+ const prevCount = usePrev(count);
111
+
112
+ return (
113
+ <div>
114
+ <p>Current count: {count}</p>
115
+ <p>Previous count: {prevCount ?? 'None'}</p>
116
+ <p>Direction: {count > prevCount ? 'Increasing' : count < prevCount ? 'Decreasing' : 'No change'}</p>
117
+ </div>
118
+ );
119
+ }
120
+ ```
121
+
122
+ #### Parameters
123
+
124
+ - `value`: The value to track
125
+
126
+ #### Returns
127
+
128
+ - The previous value (undefined on first render)
129
+
130
+ ### `useDistinct`
131
+
132
+ Detects distinct changes in values with support for deep comparison and custom equality checks. Useful for tracking whether complex objects have actually changed.
133
+
134
+ ```typescript
135
+ import { useDistinct } from 'use-good-hooks';
136
+
137
+ function UserProfileForm({ user }) {
138
+ const { distinct, value, prevValue } = useDistinct(user, { deep: true });
139
+
140
+ useEffect(() => {
141
+ if (distinct) {
142
+ console.log('User data changed from:', prevValue, 'to:', value);
143
+ // Perhaps save to backend or update UI
144
+ }
145
+ }, [distinct, prevValue, value]);
146
+
147
+ return (
148
+ <div>
149
+ <h2>Editing profile for: {user.name}</h2>
150
+ {distinct && <div className="alert">Unsaved changes!</div>}
151
+ {/* Form inputs */}
152
+ </div>
153
+ );
154
+ }
155
+ ```
156
+
157
+ #### Parameters
158
+
159
+ - `inputValue`: The value to check for changes
160
+ - `options`: (Optional) Configuration options:
161
+ - `deep`: Boolean to enable deep equality comparison (default: false)
162
+ - `compare`: Custom comparison function (a, b) => boolean
163
+ - `debounce`: Debounce time in milliseconds (default: 0)
164
+
165
+ #### Returns
166
+
167
+ - Object with:
168
+ - `distinct`: Boolean indicating if the value changed
169
+ - `prevValue`: The previous distinct value
170
+ - `value`: The current value
171
+
172
+ ### `useStorageState`
173
+
174
+ Persists state to localStorage or sessionStorage with automatic serialization/deserialization.
175
+
176
+ ```typescript
177
+ import { useStorageState } from 'use-good-hooks';
178
+
179
+ function ThemePreferences() {
180
+ const [preferences, setPreferences, { removeKey }] = useStorageState('theme-prefs', {
181
+ darkMode: false,
182
+ fontSize: 'medium',
183
+ compactView: true
184
+ });
185
+
186
+ return (
187
+ <div>
188
+ <h2>Theme Settings</h2>
189
+ <label>
190
+ <input
191
+ type="checkbox"
192
+ checked={preferences.darkMode}
193
+ onChange={() => setPreferences({...preferences, darkMode: !preferences.darkMode})}
194
+ />
195
+ Dark Mode
196
+ </label>
197
+ {/* More settings */}
198
+ <button onClick={removeKey}>Reset to Defaults</button>
199
+ </div>
200
+ );
201
+ }
202
+ ```
203
+
204
+ #### Parameters
205
+
206
+ - `key`: Storage key name
207
+ - `initialState`: Default state if no stored value exists
208
+ - `options`: (Optional) Configuration options:
209
+ - `storage`: 'local' or 'session' (default: 'local')
210
+ - `debounce`: Debounce time in milliseconds (default: 500)
211
+ - `onError`: Error callback function
212
+ - `omitKeys`: Array of keys to omit from storage or function (value, key) => boolean
213
+ - `pickKeys`: Array of keys to include in storage or function (value, key) => boolean
214
+
215
+ #### Returns
216
+
217
+ - Array with:
218
+ - State value
219
+ - State setter function
220
+ - Object with utility functions:
221
+ - `removeKey`: Function to clear the storage key
222
+
223
+ ### `useUrlState`
224
+
225
+ Synchronizes state with URL query parameters. Great for shareable UI states, filters, pagination, and search terms.
226
+
227
+ ```typescript
228
+ import { useUrlState } from 'use-good-hooks';
229
+
230
+ function ProductFilter() {
231
+ const [filters, setFilters] = useUrlState({
232
+ category: '',
233
+ minPrice: 0,
234
+ maxPrice: 1000,
235
+ sortBy: 'newest'
236
+ }, {
237
+ url: new URL(window.location.href),
238
+ kebabCase: true,
239
+ omitValues: ['', 0]
240
+ });
241
+
242
+ return (
243
+ <div>
244
+ <select
245
+ value={filters.category}
246
+ onChange={(e) => setFilters({...filters, category: e.target.value})}
247
+ >
248
+ <option value="">All Categories</option>
249
+ <option value="electronics">Electronics</option>
250
+ <option value="clothing">Clothing</option>
251
+ </select>
252
+ {/* More filter controls */}
253
+ </div>
254
+ );
255
+ }
256
+ ```
257
+
258
+ #### Parameters
259
+
260
+ - `initialState`: Default state if URL has no parameters
261
+ - `options`: Configuration options:
262
+ - `url`: URL object to use (required)
263
+ - `debounce`: Debounce time in milliseconds (default: 500)
264
+ - `kebabCase`: Convert camelCase keys to kebab-case in URL (default: true)
265
+ - `prefix`: Optional prefix for URL parameters
266
+ - `onError`: Error callback function
267
+ - `omitKeys`: Array of keys to omit from URL or function (value, key) => boolean
268
+ - `pickKeys`: Array of keys to include in URL or function (value, key) => boolean
269
+ - `omitValues`: Array of values to omit from URL or function (value, key) => boolean
270
+
271
+ #### Returns
272
+
273
+ - Array with:
274
+ - State value
275
+ - State setter function
276
+
277
+ ## 🧪 Running Tests
278
+
279
+ This library is thoroughly tested with Vitest and React Testing Library. To run the tests:
280
+
281
+ ```bash
282
+ # Using npm
283
+ npm test
284
+
285
+ # Using yarn
286
+ yarn test
287
+
288
+ # Using pnpm
289
+ pnpm test
290
+ ```
291
+
292
+ ## 🔄 How hooks update and optimize performance
293
+
294
+ Each hook in this library is designed with performance in mind:
295
+
296
+ 1. `useDebounce` and `useThrottle` reduce unnecessary renders using Lodash's optimized implementations
297
+ 2. `useDistinct` avoids reference equality problems with optional deep comparison
298
+ 3. `useStorageState` batches storage updates to reduce expensive serialization/deserialization
299
+ 4. `useUrlState` efficiently handles URL synchronization with debouncing
300
+
301
+ ## 🛠️ Development
302
+
303
+ ```bash
304
+ # Install dependencies
305
+ yarn install
306
+
307
+ # Start development server
308
+ yarn dev
309
+
310
+ # Run tests
311
+ yarn test
312
+
313
+ # Build the library
314
+ yarn build
315
+
316
+ # Lint and format the code
317
+ yarn lint
318
+ ```
319
+
320
+ ## 🙏 Acknowledgements
321
+
322
+ This project was built with:
323
+
324
+ - [React](https://reactjs.org/)
325
+ - [TypeScript](https://www.typescriptlang.org/)
326
+ - [Vite](https://vitejs.dev/)
327
+ - [Vitest](https://vitest.dev/)
328
+ - [Lodash](https://lodash.com/)
329
+ - [use-json](https://www.npmjs.com/package/use-json)
330
+ - [use-qs](https://www.npmjs.com/package/use-qs)
331
+
332
+ ## 📝 License
333
+
334
+ MIT © [Felipe Rohde](mailto:feliperohdee@gmail.com)
335
+
336
+ ## 👨‍💻 Author
337
+
338
+ **Felipe Rohde**
339
+
340
+ - Twitter: [@felipe_rohde](https://twitter.com/felipe_rohde)
341
+ - Github: [@feliperohdee](https://github.com/feliperohdee)
342
+ - Email: feliperohdee@gmail.com
343
+
@@ -0,0 +1 @@
1
+ export { }
@@ -0,0 +1,192 @@
1
+ import t from "lodash";
2
+ import { useState as S, useRef as b, useEffect as y, useCallback as D } from "react";
3
+ import k from "use-json";
4
+ import w from "use-qs";
5
+ const _ = 300, R = (r, e = _) => {
6
+ const [l, d] = S(r), f = b(
7
+ t.debounce((p) => {
8
+ d(p);
9
+ }, e)
10
+ );
11
+ return y(() => {
12
+ f.current(r);
13
+ }, [r]), y(() => {
14
+ const p = f.current;
15
+ return () => {
16
+ p.cancel();
17
+ };
18
+ }, []), l;
19
+ }, B = (r, e) => r === e, M = (r, e = {}) => {
20
+ const l = b(!1), d = b(e), f = b(r), p = b(
21
+ t.debounce((i) => {
22
+ const { compare: o, deep: s } = d.current;
23
+ !(t.isFunction(o) ? o : s ? t.isEqual : B)(i, f.current) && (F({
24
+ distinct: !0,
25
+ prevValue: f.current,
26
+ value: i
27
+ }), f.current = i);
28
+ }, d.current.debounce ?? 0)
29
+ ), [m, F] = S({
30
+ distinct: !1,
31
+ prevValue: void 0,
32
+ value: r
33
+ }), a = D(() => {
34
+ l.current = !1, F((i) => ({
35
+ ...i,
36
+ distinct: !1
37
+ }));
38
+ }, []);
39
+ return y(() => {
40
+ if (m.distinct && l.current) {
41
+ a();
42
+ return;
43
+ }
44
+ const i = p.current;
45
+ i(r);
46
+ }), y(() => {
47
+ m.distinct && (l.current = !0);
48
+ }, [m.distinct]), y(() => {
49
+ const i = p.current;
50
+ return () => {
51
+ i.cancel();
52
+ };
53
+ }, []), m;
54
+ }, O = (r) => {
55
+ const e = b(void 0);
56
+ return y(() => {
57
+ e.current = r;
58
+ }), e.current;
59
+ }, A = () => typeof window < "u", g = { browser: A }, K = 500, h = "local", U = (r, e) => t.isMap(e) ? {
60
+ __type: "Map",
61
+ value: Array.from(e.entries())
62
+ } : t.isSet(e) ? {
63
+ __type: "Set",
64
+ value: Array.from(e)
65
+ } : e, z = (r, e) => e && e.__type === "Map" ? new Map(e.value) : e && e.__type === "Set" ? new Set(e.value) : e, $ = (r, e, l) => {
66
+ const d = b(l || {}), f = b(!1), [p, m] = S(e), F = R(
67
+ p,
68
+ d.current.debounce ?? K
69
+ );
70
+ y(() => {
71
+ if (!g.browser() || !f.current)
72
+ return;
73
+ const {
74
+ storage: i = h,
75
+ onError: o,
76
+ omitKeys: s,
77
+ pickKeys: u
78
+ } = d.current, c = i === "local" ? localStorage : sessionStorage;
79
+ if (c)
80
+ try {
81
+ let n = F;
82
+ s && (t.size(s) || t.isFunction(s)) && (n = t.isFunction(s) ? t.omitBy(n, s) : t.omit(n, s)), u && (t.size(u) || t.isFunction(u)) && (n = t.isFunction(u) ? t.pickBy(n, u) : t.pick(n, u)), c.setItem(r, k.stringify(n, U));
83
+ } catch (n) {
84
+ o && o(n);
85
+ }
86
+ }, [r, F]), y(() => {
87
+ const {
88
+ storage: i = h,
89
+ omitKeys: o,
90
+ onError: s,
91
+ pickKeys: u
92
+ } = d.current;
93
+ try {
94
+ const c = i === "local" ? localStorage : sessionStorage, n = c == null ? void 0 : c.getItem(r);
95
+ if (n) {
96
+ let E = k.parse(n, z);
97
+ o && (t.size(o) || t.isFunction(o)) && (E = t.isFunction(o) ? t.omitBy(E, o) : t.omit(E, o)), u && (t.size(u) || t.isFunction(u)) && (E = t.isFunction(u) ? t.pickBy(E, u) : t.pick(E, u)), m(
98
+ t.isEmpty(E) ? e : t.merge({}, e, E)
99
+ );
100
+ }
101
+ } catch (c) {
102
+ s && s(c);
103
+ } finally {
104
+ f.current = !0;
105
+ }
106
+ }, []);
107
+ const a = D(() => {
108
+ if (!g.browser())
109
+ return;
110
+ m(e);
111
+ const { storage: i = h } = d.current, o = i === "local" ? localStorage : sessionStorage;
112
+ o == null || o.removeItem(r);
113
+ }, [r, e]);
114
+ return [p, m, { removeKey: a }];
115
+ }, C = 300, v = (r, e = C) => {
116
+ const [l, d] = S(r), f = b(
117
+ t.throttle((p) => {
118
+ d(p);
119
+ }, e)
120
+ );
121
+ return y(() => {
122
+ f.current(r);
123
+ }, [r]), y(() => {
124
+ const p = f.current;
125
+ return () => {
126
+ p.cancel();
127
+ };
128
+ }, []), l;
129
+ }, T = 500, x = (r, e) => {
130
+ const l = b(e), d = () => {
131
+ const {
132
+ kebabCase: F = !0,
133
+ omitKeys: a,
134
+ omitValues: i,
135
+ onError: o,
136
+ pickKeys: s,
137
+ prefix: u = "",
138
+ url: c
139
+ } = l.current;
140
+ if (!c.search)
141
+ return r;
142
+ try {
143
+ let n = w.parse(decodeURIComponent(c.search), {
144
+ case: F ? "kebab-case" : "camelCase",
145
+ omitValues: i,
146
+ prefix: u
147
+ });
148
+ return a && (t.size(a) || t.isFunction(a)) && (n = t.isFunction(a) ? t.omitBy(n, a) : t.omit(n, a)), s && (t.size(s) || t.isFunction(s)) && (n = t.isFunction(s) ? t.pickBy(n, s) : t.pick(n, s)), t.isEmpty(n) ? r : t.merge({}, r, n);
149
+ } catch (n) {
150
+ return o && o(n), r;
151
+ }
152
+ }, [f, p] = S(d), m = R(
153
+ f,
154
+ l.current.debounce ?? T
155
+ );
156
+ return y(() => {
157
+ if (!g.browser())
158
+ return;
159
+ const {
160
+ kebabCase: F = !0,
161
+ omitKeys: a,
162
+ omitValues: i,
163
+ onError: o,
164
+ pickKeys: s,
165
+ prefix: u = ""
166
+ } = l.current;
167
+ try {
168
+ let c = m;
169
+ a && (t.size(a) || t.isFunction(a)) && (c = t.isFunction(a) ? t.omitBy(c, a) : t.omit(c, a)), s && (t.size(s) || t.isFunction(s)) && (c = t.isFunction(s) ? t.pickBy(c, s) : t.pick(c, s));
170
+ const n = w.stringify(c, {
171
+ case: F ? "kebab-case" : "camelCase",
172
+ omitValues: i,
173
+ prefix: u
174
+ });
175
+ n ? window.history.replaceState(
176
+ {},
177
+ "",
178
+ `${window.location.pathname}${n}`
179
+ ) : window.history.replaceState({}, "", window.location.pathname);
180
+ } catch (c) {
181
+ o && o(c);
182
+ }
183
+ }, [m]), [f, p];
184
+ };
185
+ export {
186
+ R as useDebounce,
187
+ M as useDistinct,
188
+ O as usePrev,
189
+ $ as useStorageState,
190
+ v as useThrottle,
191
+ x as useUrlState
192
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "dependencies": {
3
+ "use-json": "^1.0.0",
4
+ "use-qs": "^1.0.4"
5
+ },
6
+ "devDependencies": {
7
+ "@eslint/js": "^9.19.0",
8
+ "@testing-library/dom": "^10.4.0",
9
+ "@testing-library/jest-dom": "^6.6.3",
10
+ "@testing-library/react": "^16.2.0",
11
+ "@types/lodash": "^4.17.16",
12
+ "@types/node": "^22.13.1",
13
+ "@types/react": "^19.0.8",
14
+ "@types/react-dom": "^19.0.3",
15
+ "@vitejs/plugin-react": "^4.3.4",
16
+ "clsx": "^2.1.1",
17
+ "eslint": "^9.19.0",
18
+ "eslint-plugin-react-hooks": "^5.0.0",
19
+ "eslint-plugin-react-refresh": "^0.4.18",
20
+ "globals": "^16.0.0",
21
+ "jsdom": "^26.0.0",
22
+ "lodash": "^4.17.21",
23
+ "prettier": "^3.4.2",
24
+ "react": "^19.0.0",
25
+ "react-dom": "^19.0.0",
26
+ "typescript": "~5.8.2",
27
+ "typescript-eslint": "^8.22.0",
28
+ "vite": "^6.2.0",
29
+ "vite-plugin-dts": "^4.5.3",
30
+ "vitest": "^3.0.7"
31
+ },
32
+ "files": [
33
+ "dist-export"
34
+ ],
35
+ "main": "dist-export/index.js",
36
+ "module": "index.ts",
37
+ "name": "use-good-hooks",
38
+ "peerDependencies": {
39
+ "lodash": "^4.17.21",
40
+ "react": "^19.0.0",
41
+ "react-dom": "^19.0.0"
42
+ },
43
+ "scripts": {
44
+ "build": "vite build",
45
+ "build:export": "yarn lint && yarn build --outDir ./dist-export --mode export",
46
+ "dev": "vite",
47
+ "lint": "prettier --write . && eslint .",
48
+ "test": "vitest"
49
+ },
50
+ "type": "module",
51
+ "version": "1.0.0"
52
+ }