Skip to content

Commit 371d71c

Browse files
feat(useMediaQuery): enhance hook with debounce, fallback options, and onChange callback
1 parent ac364a2 commit 371d71c

3 files changed

Lines changed: 288 additions & 11 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# useMediaQuery
2+
3+
A performant, SSR-safe React hook for tracking media queries using `useSyncExternalStore`. Optimized for Next.js and React 18+ with support for legacy browsers.
4+
5+
## Basic Usage
6+
7+
Perfect for simple responsive logic in client components.
8+
9+
```jsx
10+
import { useMediaQuery } from '@barso/hooks';
11+
12+
function SimpleComponent() {
13+
const isMobile = useMediaQuery('(max-width: 768px)');
14+
15+
return <div>{isMobile ? 'Viewing on Mobile' : 'Viewing on Desktop'}</div>;
16+
}
17+
```
18+
19+
## Features
20+
21+
-**React 18 Ready**: Uses `useSyncExternalStore` to prevent "tearing" and ensure consistency.
22+
- 🌐 **SSR/Next.js Compatible**: Handles hydration gracefully with customizable fallbacks.
23+
- ⏱️ **Built-in Debounce**: Optional delay to prevent excessive re-renders during window resizing.
24+
- 🔄 **Legacy Support**: Automatic fallback for browsers not supporting `addEventListener` on `matchMedia`.
25+
- 🎣 **Event Callback**: Integrated `onChange` listener for side effects.
26+
27+
## Advanced Example: Theming with `prefers-color-scheme`
28+
29+
To prevent **Flash of Unstyled Content (FOUC)** or layout shifts in Next.js, you can sync the server-side state (derived from cookies or user-agent) with the hook using the `fallback` option.
30+
31+
```jsx
32+
// app/page.js (Server Component)
33+
import { cookies } from 'next/headers';
34+
import ThemeWrapper from './ThemeWrapper';
35+
36+
export default function Page() {
37+
// Read the saved theme from cookies to ensure the server renders the correct UI
38+
const themeCookie = cookies().get('theme')?.value;
39+
const isDarkMode = themeCookie === 'dark';
40+
41+
return <ThemeWrapper initialIsDark={isDarkMode} />;
42+
}
43+
44+
// ThemeWrapper.js (Client Component)
45+
'use client';
46+
import { useMediaQuery } from '@barso/hooks';
47+
48+
export default function ThemeWrapper({ initialIsDark }) {
49+
const isDark = useMediaQuery('(prefers-color-scheme: dark)', {
50+
// Use the server-provided state during hydration
51+
fallback: initialIsDark,
52+
// Optional: add debounce for smoother transitions
53+
debounceMs: 200,
54+
onChange: (matches) => {
55+
console.log('Theme changed to:', matches ? 'dark' : 'light');
56+
}
57+
});
58+
59+
return (
60+
<div className={isDark ? 'dark-mode' : 'light-mode'}>
61+
<h1>Themed Content</h1>
62+
</div>
63+
);
64+
}
65+
```
66+
67+
## API Reference
68+
69+
### `useMediaQuery(query, options)`
70+
71+
| Argument | Type | Description |
72+
| --------- | -------- | ------------------------------------------------------- |
73+
| `query` | `string` | The media query to track (e.g., `(min-width: 1024px)`). |
74+
| `options` | `object` | Configuration object. |
75+
76+
### Options
77+
78+
| Property | Type | Default | Description |
79+
| ------------ | -------------------------------- | ----------- | -------------------------------------------------- |
80+
| `debounceMs` | `number` | `undefined` | Delay in ms to wait before updating the state. |
81+
| `fallback` | `boolean` &#124; `() => boolean` | `false` | Initial value used on server and during hydration. |
82+
| `onChange` | `(matches: boolean) => void` | `undefined` | Callback function triggered on every change. |
Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,74 @@
1-
import { useEffect, useState } from 'react';
1+
import { noop } from '@barso/helpers';
2+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
23

3-
export function useMediaQuery(query) {
4-
const [matches, setMatches] = useState(() => typeof window !== 'undefined' && !!window.matchMedia?.(query)?.matches);
4+
/**
5+
* @typedef {Object} UseMediaQueryOptions
6+
* @property {number} [debounceMs] - Delay in ms before updating the state.
7+
* @property {boolean | (() => boolean)} [fallback=false] - Initial state used during SSR and hydration.
8+
* @property {(matches: boolean) => void} [onChange] - Callback fired when the query match state changes.
9+
*/
510

6-
useEffect(() => {
7-
const media = window.matchMedia(query);
8-
const listener = () => setMatches(media.matches);
11+
/**
12+
* A robust React hook to monitor media queries, optimized for Next.js and React 18+.
13+
* @param {string} query - The media query string to monitor (e.g., '(max-width: 768px)').
14+
* @param {UseMediaQueryOptions} [options] - Optional configuration for debounce, SSR fallback, and change events.
15+
* @returns {boolean} - Returns true if the media query matches, false otherwise.
16+
*/
17+
export function useMediaQuery(query, { debounceMs, fallback = false, onChange } = {}) {
18+
const getServerSnapshot = () => (typeof fallback === 'function' ? fallback() : fallback);
919

10-
listener();
20+
const mql = useMemo(() => {
21+
if (typeof window === 'undefined' || !window.matchMedia) {
22+
return { matches: getServerSnapshot(), isFallback: true };
23+
}
1124

12-
media.addEventListener('change', listener);
13-
return () => media.removeEventListener('change', listener);
25+
return window.matchMedia(query);
26+
// eslint-disable-next-line react-hooks/exhaustive-deps
1427
}, [query]);
1528

29+
const timeoutRef = useRef();
30+
31+
const subscribe = useCallback(
32+
(notify) => {
33+
if (mql.isFallback) return noop;
34+
35+
const handleChange = () => {
36+
if (!debounceMs) return notify();
37+
38+
clearTimeout(timeoutRef.current);
39+
timeoutRef.current = setTimeout(notify, debounceMs);
40+
};
41+
42+
if (!mql.addEventListener) {
43+
mql.addListener?.(handleChange);
44+
return () => {
45+
mql.removeListener?.(handleChange);
46+
clearTimeout(timeoutRef.current);
47+
};
48+
}
49+
50+
mql.addEventListener('change', handleChange);
51+
52+
return () => {
53+
mql.removeEventListener('change', handleChange);
54+
clearTimeout(timeoutRef.current);
55+
};
56+
},
57+
[debounceMs, mql],
58+
);
59+
60+
const getSnapshot = () => mql.matches;
61+
62+
const matches = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
63+
64+
const lastValueRef = useRef(matches);
65+
66+
useEffect(() => {
67+
if (typeof onChange !== 'function' || matches === lastValueRef.current) return;
68+
69+
lastValueRef.current = matches;
70+
onChange(matches);
71+
}, [matches, onChange]);
72+
1673
return matches;
1774
}

packages/hooks/src/useMediaQuery/useMediaQuery.test.js

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ describe('useMediaQuery', () => {
2020
});
2121

2222
beforeEach(() => {
23+
vi.useFakeTimers();
2324
matches = false;
2425
listeners.clear();
2526
});
2627

2728
afterAll(() => {
28-
window.matchMedia.mockRestore();
29+
vi.restoreAllMocks();
2930
});
3031

3132
it('should return true if the media query matches', () => {
@@ -47,14 +48,151 @@ describe('useMediaQuery', () => {
4748
matches = true;
4849
listeners.forEach((cb) => cb());
4950
});
51+
52+
expect(result.current).toBe(true);
53+
});
54+
55+
it('should handle debounce correctly', async () => {
56+
const { result } = renderHook(() => useMediaQuery('(max-width: 768px)', { debounceMs: 100 }));
57+
58+
act(() => {
59+
matches = true;
60+
listeners.forEach((cb) => cb());
61+
});
62+
63+
expect(result.current).toBe(false);
64+
await act(() => vi.advanceTimersByTimeAsync(100));
5065
expect(result.current).toBe(true);
5166
});
67+
68+
it('should call onChange when media query changes', () => {
69+
const onChange = vi.fn();
70+
71+
renderHook(() => useMediaQuery('(max-width: 768px)', { onChange }));
72+
73+
act(() => {
74+
matches = true;
75+
listeners.forEach((cb) => cb());
76+
});
77+
78+
expect(onChange).toHaveBeenCalledExactlyOnceWith(true);
79+
});
80+
81+
it('should cleanup listeners on unmount', () => {
82+
const { unmount } = renderHook(() => useMediaQuery('(min-width: 800px)'));
83+
expect(listeners.size).toBe(1);
84+
85+
unmount();
86+
expect(listeners.size).toBe(0);
87+
});
88+
89+
it('should cleanup debounce timers on unmount', () => {
90+
const { unmount } = renderHook(() => useMediaQuery('(max-width: 768px)', { debounceMs: 100 }));
91+
92+
act(() => {
93+
matches = true;
94+
listeners.forEach((cb) => cb());
95+
});
96+
expect(vi.getTimerCount()).toBe(1);
97+
98+
unmount();
99+
expect(vi.getTimerCount()).toBe(0);
100+
});
101+
102+
describe('legacy addListener/removeListener support', () => {
103+
beforeAll(() => {
104+
window.matchMedia.mockImplementation((query) => ({
105+
get matches() {
106+
return matches;
107+
},
108+
media: query,
109+
addListener: (cb) => listeners.add(cb),
110+
removeListener: (cb) => listeners.delete(cb),
111+
}));
112+
});
113+
114+
it('should matches and update correctly using legacy methods', () => {
115+
const { result } = renderHook(() => useMediaQuery('(min-width: 900px)'));
116+
expect(result.current).toBe(false);
117+
118+
act(() => {
119+
matches = true;
120+
listeners.forEach((cb) => cb());
121+
});
122+
expect(result.current).toBe(true);
123+
});
124+
125+
it('should cleanup listeners on unmount', () => {
126+
const { unmount } = renderHook(() => useMediaQuery('(min-width: 800px)'));
127+
expect(listeners.size).toBe(1);
128+
129+
unmount();
130+
expect(listeners.size).toBe(0);
131+
});
132+
133+
it('should cleanup debounce timers on unmount', () => {
134+
const { unmount } = renderHook(() => useMediaQuery('(max-width: 768px)', { debounceMs: 100 }));
135+
136+
act(() => {
137+
matches = true;
138+
listeners.forEach((cb) => cb());
139+
});
140+
expect(vi.getTimerCount()).toBe(1);
141+
142+
unmount();
143+
expect(vi.getTimerCount()).toBe(0);
144+
});
145+
});
52146
});
53147

54148
describe('SSR', () => {
55-
it('should not throw during SSR and return false', () => {
149+
let originalWindow;
150+
151+
beforeAll(() => {
152+
originalWindow = global.window;
153+
delete global.window;
154+
});
155+
156+
afterAll(() => {
157+
global.window = originalWindow;
158+
});
159+
160+
it('should default to false when no fallback is provided', () => {
56161
const TestComponent = () => String(useMediaQuery('(min-width: 600px)'));
57162
expect(renderToString(<TestComponent />)).toBe('false');
58163
});
164+
165+
it('should use the constant boolean provided as fallback', () => {
166+
const TestComponent = () => String(useMediaQuery('(max-width: 768px)', { fallback: true }));
167+
expect(renderToString(<TestComponent />)).toBe('true');
168+
});
169+
170+
it.each([true, false])('should execute the fallback function and use its return value (%s)', (fallbackValue) => {
171+
const TestComponent = () => String(useMediaQuery('(max-width: 768px)', { fallback: () => fallbackValue }));
172+
expect(renderToString(<TestComponent />)).toBe(String(fallbackValue));
173+
});
174+
});
175+
176+
describe('when window.matchMedia is not available', () => {
177+
let originalMatchMedia;
178+
179+
beforeAll(() => {
180+
originalMatchMedia = window.matchMedia;
181+
delete window.matchMedia;
182+
});
183+
184+
afterAll(() => {
185+
window.matchMedia = originalMatchMedia;
186+
});
187+
188+
it('should return false when no fallback is provided', () => {
189+
const { result } = renderHook(() => useMediaQuery('(min-width: 600px)'));
190+
expect(result.current).toBe(false);
191+
});
192+
193+
it('should return the fallback value', () => {
194+
const { result } = renderHook(() => useMediaQuery('(min-width: 600px)', { fallback: true }));
195+
expect(result.current).toBe(true);
196+
});
59197
});
60198
});

0 commit comments

Comments
 (0)