Skip to content

Commit 4e8446b

Browse files
committed
main 🧊 add optimization article, add use window focus tests
1 parent 3f606a0 commit 4e8446b

5 files changed

Lines changed: 160 additions & 23 deletions

File tree

β€Žpackages/core/src/bundle/hooks/useDropZone/useDropZone.jsβ€Ž

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export const useDropZone = (...params) => {
8686
if (!target && !internalRef.state) return;
8787
const element = target ? isTarget.getElement(target) : internalRef.current;
8888
if (!element) return;
89-
const onEvent = (event, type) => {
89+
const onEvent = (event) => {
9090
if (!event.dataTransfer) return;
9191
const isValid = checkValidity(event.dataTransfer.items);
9292
if (!isValid) {
@@ -96,32 +96,32 @@ export const useDropZone = (...params) => {
9696
event.preventDefault();
9797
event.dataTransfer.dropEffect = 'copy';
9898
const currentFiles = getFiles(event);
99-
if (type === 'drop') {
99+
if (event.type === 'drop') {
100100
counterRef.current = 0;
101101
setOvered(false);
102102
setFiles(currentFiles);
103103
options.onDrop?.(currentFiles, event);
104104
return;
105105
}
106-
if (type === 'enter') {
106+
if (event.type === 'dragenter') {
107107
counterRef.current += 1;
108108
setOvered(true);
109109
options.onEnter?.(event);
110110
return;
111111
}
112-
if (type === 'leave') {
112+
if (event.type === 'dragleave') {
113113
counterRef.current -= 1;
114114
if (counterRef.current !== 0) return;
115115
setOvered(false);
116116
options.onLeave?.(event);
117117
return;
118118
}
119-
if (type === 'over') options.onOver?.(event);
119+
if (event.type === 'dragover') options.onOver?.(event);
120120
};
121-
const onDrop = (event) => onEvent(event, 'drop');
122-
const onDragOver = (event) => onEvent(event, 'over');
123-
const onDragEnter = (event) => onEvent(event, 'enter');
124-
const onDragLeave = (event) => onEvent(event, 'leave');
121+
const onDrop = (event) => onEvent(event);
122+
const onDragOver = (event) => onEvent(event);
123+
const onDragEnter = (event) => onEvent(event);
124+
const onDragLeave = (event) => onEvent(event);
125125
element.addEventListener('dragenter', onDragEnter);
126126
element.addEventListener('dragover', onDragOver);
127127
element.addEventListener('dragleave', onDragLeave);

β€Žpackages/core/src/hooks/useWindowFocus/useWindowFocus.test.tsβ€Ž

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,19 @@ it('Should use window focus on server side', () => {
1616
expect(result.current).toBe(false);
1717
});
1818

19-
it('Should update state on focus and blur events', () => {
19+
it('Should update state on focus event', () => {
20+
const { result } = renderHook(useWindowFocus);
21+
22+
expect(result.current).toBe(false);
23+
24+
act(() => {
25+
window.dispatchEvent(new Event('focus'));
26+
});
27+
28+
expect(result.current).toBe(true);
29+
});
30+
31+
it('Should update state on blur event', () => {
2032
const { result } = renderHook(useWindowFocus);
2133

2234
expect(result.current).toBe(false);
@@ -48,15 +60,3 @@ it('Should cleanup on unmount', () => {
4860
expect(removeEventListenerSpy).toHaveBeenCalledWith('focus', expect.any(Function));
4961
expect(removeEventListenerSpy).toHaveBeenCalledWith('blur', expect.any(Function));
5062
});
51-
52-
it('Should not update state after unmount', () => {
53-
const { result, unmount } = renderHook(useWindowFocus);
54-
55-
unmount();
56-
57-
act(() => {
58-
window.dispatchEvent(new Event('focus'));
59-
});
60-
61-
expect(result.current).toBe(false);
62-
});

β€Žpackages/docs/app/.vitepress/config.mtsβ€Ž

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ export default async () => {
179179
{ text: 'reactuse.json', link: '/reactuse-json' },
180180
{ text: 'CLI', link: '/cli' },
181181
{ text: 'target', link: '/target' },
182-
{ text: 'memoization', link: '/memoization' }
182+
{ text: 'memoization', link: '/memoization' },
183+
{ text: 'optimization', link: '/optimization' }
183184
]
184185
},
185186
{
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Optimization
2+
3+
## Library philosophy
4+
5+
ReactUse provides **simple, predictable utility hooks**. We do not add shared caches or cross-component optimizations by default. Optimization is a **conscious, application-level decision**, not something the library imposes on every user.
6+
7+
## Why the library stays simple
8+
9+
> "Premature optimization is the root of all evil" β€” Donald Knuth
10+
11+
The same principle applies here as with [memoization](/memoization): optimization should be applied when there is a real need, not by default.
12+
13+
- **Simple hooks are easier to reason about and debug** β€” one subscription per hook instance, no hidden shared state.
14+
- **Not every app needs one-listener-per-query** β€” pushing shared stores into the library would add complexity for everyone, including those who do not need it.
15+
- **Users who need shared or subscription optimizations** can implement or wrap hooks in their application, or use helpers when appropriate.
16+
17+
## Hooks that can be optimized
18+
19+
The library ships a straightforward `useMediaQuery`: each component that uses it gets its own `matchMedia` subscription and change listener. No shared cache.
20+
21+
**Current implementation:**
22+
23+
```ts
24+
import { useCallback, useSyncExternalStore } from 'react';
25+
26+
const getServerSnapshot = () => false;
27+
28+
export const useMediaQuery = (query: string) => {
29+
const subscribe = useCallback(
30+
(callback: () => void) => {
31+
const matchMedia = window.matchMedia(query);
32+
33+
// Each hook call gets its own listener
34+
matchMedia.addEventListener('change', callback);
35+
return () => {
36+
matchMedia.removeEventListener('change', callback);
37+
};
38+
},
39+
[query]
40+
);
41+
42+
const getSnapshot = () => window.matchMedia(query).matches;
43+
44+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
45+
};
46+
```
47+
48+
If you measure and find that many components use the same query and you want a single listener per query, you can do that **in your application**. A module-level cache of external stores, one `MediaQueryList` and one `change` listener per unique query.
49+
50+
**Optimized variant:**
51+
52+
```ts
53+
import { useSyncExternalStore } from 'react';
54+
55+
const getServerSnapshot = () => false;
56+
57+
interface MediaQueryListExternalStore {
58+
getSnapshot: () => boolean;
59+
subscribe: (onStoreChange: () => void) => () => void;
60+
}
61+
62+
const mediaQueryListExternalStore = new Map<string, MediaQueryListExternalStore>();
63+
64+
const createMediaQueryExternalStore = (query: string): MediaQueryListExternalStore => {
65+
const mediaQueryList = window.matchMedia(query);
66+
const listeners = new Set<() => void>();
67+
const onChange = () => listeners.forEach((listener) => listener());
68+
69+
const store: MediaQueryListExternalStore = {
70+
subscribe: (onStoreChange) => {
71+
listeners.add(onStoreChange);
72+
if (listeners.size === 1) mediaQueryList.addEventListener('change', onChange);
73+
return () => {
74+
listeners.delete(onStoreChange);
75+
if (listeners.size === 0) {
76+
mediaQueryList.removeEventListener('change', onChange);
77+
}
78+
};
79+
},
80+
getSnapshot: () => mediaQueryList.matches
81+
};
82+
mediaQueryListExternalStore.set(query, store);
83+
return store;
84+
};
85+
86+
const getMediaQueryExternalStore = (query: string) =>
87+
mediaQueryListExternalStore.get(query) ?? createMediaQueryExternalStore(query);
88+
89+
export const useMediaQuery = (query: string) => {
90+
const store = getMediaQueryExternalStore(query);
91+
return useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot);
92+
};
93+
```
94+
95+
The library keeps the simple version by design; the optimized version is something you can adopt in your codebase if it fits your needs.
96+
97+
## createSharedHook
98+
99+
ReactUse also provides [**createSharedHook**](/functions/hooks/createSharedHook.html): a helper that creates one shared instance of a hook globally. The first subscriber's arguments are used; when the number of subscribers drops to zero, the internal runner unmounts.
100+
101+
```tsx
102+
import { createSharedHook, useMediaQuery } from '@siberiacancode/reactuse';
103+
104+
const useSharedMediaQuery = createSharedHook(useMediaQuery);
105+
106+
const First = () => {
107+
const matches = useSharedMediaQuery('(max-width: 768px)');
108+
return (
109+
<div>
110+
This is <code>{matches ? 'mobile' : 'desktop'}</code> screen
111+
</div>
112+
);
113+
};
114+
115+
const Second = () => {
116+
const matches = useSharedMediaQuery('(max-width: 768px)');
117+
return (
118+
<div>
119+
This is <code>{matches ? 'mobile' : 'desktop'}</code> screen
120+
</div>
121+
);
122+
};
123+
124+
const Demo = () => (
125+
<div>
126+
<First />
127+
<Second />
128+
</div>
129+
);
130+
```
131+
132+
**Important:** **createSharedHook is experimental.** It mounts an internal component in memory and has limitations: hook order is fixed, and the first subscriber's arguments are used for everyone. For production shared state, we recommend:
133+
134+
- **External stores + useSyncExternalStore** β€” e.g. the optimized useMediaQuery pattern above, or your own store keyed by query or other arguments.
135+
- **Dedicated state libraries** β€” such as Zustand, Reatom, Effector, or similar β€” especially for complex shared state, as noted in the `createSharedHook` source.

β€Žpackages/docs/eslint.config.mjsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default eslint(
1111
{
1212
name: 'siberiacancode/reactuse/md',
1313
rules: {
14+
'react-refresh/only-export-components': 'off',
1415
'react-hooks/rules-of-hooks': 'off',
1516
'style/max-len': 'off'
1617
}

0 commit comments

Comments
Β (0)