Skip to content

Commit adef03d

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

File tree

3 files changed

+187
-10
lines changed

3 files changed

+187
-10
lines changed
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 \| () => 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: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,52 @@ describe('useMediaQuery', () => {
4747
matches = true;
4848
listeners.forEach((cb) => cb());
4949
});
50+
51+
expect(result.current).toBe(true);
52+
});
53+
54+
it('should handle debounce correctly', async () => {
55+
vi.useFakeTimers();
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 vi.runAllTimersAsync();
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+
});
5280
});
5381

5482
describe('SSR', () => {
55-
it('should not throw during SSR and return false', () => {
83+
it('should default to false when no fallback is provided', () => {
5684
const TestComponent = () => String(useMediaQuery('(min-width: 600px)'));
5785
expect(renderToString(<TestComponent />)).toBe('false');
5886
});
87+
88+
it('should use the constant boolean provided as fallback', () => {
89+
const TestComponent = () => String(useMediaQuery('(max-width: 768px)', { fallback: true }));
90+
expect(renderToString(<TestComponent />)).toBe('true');
91+
});
92+
93+
it.each([true, false])('should execute the fallback function and use its return value (%s)', (fallbackValue) => {
94+
const TestComponent = () => String(useMediaQuery('(max-width: 768px)', { fallback: () => fallbackValue }));
95+
expect(renderToString(<TestComponent />)).toBe(String(fallbackValue));
96+
});
5997
});
6098
});

0 commit comments

Comments
 (0)