From 371d71c9c92cc485928404dc43db826415214a3e Mon Sep 17 00:00:00 2001 From: Felipe Barso <77860630+aprendendofelipe@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:17:17 -0300 Subject: [PATCH] feat(useMediaQuery): enhance hook with debounce, fallback options, and onChange callback --- packages/hooks/src/useMediaQuery/README.md | 82 ++++++++++ .../hooks/src/useMediaQuery/useMediaQuery.js | 75 +++++++-- .../src/useMediaQuery/useMediaQuery.test.js | 142 +++++++++++++++++- 3 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 packages/hooks/src/useMediaQuery/README.md diff --git a/packages/hooks/src/useMediaQuery/README.md b/packages/hooks/src/useMediaQuery/README.md new file mode 100644 index 00000000..7c182e2d --- /dev/null +++ b/packages/hooks/src/useMediaQuery/README.md @@ -0,0 +1,82 @@ +# useMediaQuery + +A performant, SSR-safe React hook for tracking media queries using `useSyncExternalStore`. Optimized for Next.js and React 18+ with support for legacy browsers. + +## Basic Usage + +Perfect for simple responsive logic in client components. + +```jsx +import { useMediaQuery } from '@barso/hooks'; + +function SimpleComponent() { + const isMobile = useMediaQuery('(max-width: 768px)'); + + return
{isMobile ? 'Viewing on Mobile' : 'Viewing on Desktop'}
; +} +``` + +## Features + +- ⚡ **React 18 Ready**: Uses `useSyncExternalStore` to prevent "tearing" and ensure consistency. +- 🌐 **SSR/Next.js Compatible**: Handles hydration gracefully with customizable fallbacks. +- ⏱️ **Built-in Debounce**: Optional delay to prevent excessive re-renders during window resizing. +- 🔄 **Legacy Support**: Automatic fallback for browsers not supporting `addEventListener` on `matchMedia`. +- 🎣 **Event Callback**: Integrated `onChange` listener for side effects. + +## Advanced Example: Theming with `prefers-color-scheme` + +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. + +```jsx +// app/page.js (Server Component) +import { cookies } from 'next/headers'; +import ThemeWrapper from './ThemeWrapper'; + +export default function Page() { + // Read the saved theme from cookies to ensure the server renders the correct UI + const themeCookie = cookies().get('theme')?.value; + const isDarkMode = themeCookie === 'dark'; + + return ; +} + +// ThemeWrapper.js (Client Component) +'use client'; +import { useMediaQuery } from '@barso/hooks'; + +export default function ThemeWrapper({ initialIsDark }) { + const isDark = useMediaQuery('(prefers-color-scheme: dark)', { + // Use the server-provided state during hydration + fallback: initialIsDark, + // Optional: add debounce for smoother transitions + debounceMs: 200, + onChange: (matches) => { + console.log('Theme changed to:', matches ? 'dark' : 'light'); + } + }); + + return ( +
+

Themed Content

+
+ ); +} +``` + +## API Reference + +### `useMediaQuery(query, options)` + +| Argument | Type | Description | +| --------- | -------- | ------------------------------------------------------- | +| `query` | `string` | The media query to track (e.g., `(min-width: 1024px)`). | +| `options` | `object` | Configuration object. | + +### Options + +| Property | Type | Default | Description | +| ------------ | -------------------------------- | ----------- | -------------------------------------------------- | +| `debounceMs` | `number` | `undefined` | Delay in ms to wait before updating the state. | +| `fallback` | `boolean` | `() => boolean` | `false` | Initial value used on server and during hydration. | +| `onChange` | `(matches: boolean) => void` | `undefined` | Callback function triggered on every change. | diff --git a/packages/hooks/src/useMediaQuery/useMediaQuery.js b/packages/hooks/src/useMediaQuery/useMediaQuery.js index 96cbbb56..bf52853f 100644 --- a/packages/hooks/src/useMediaQuery/useMediaQuery.js +++ b/packages/hooks/src/useMediaQuery/useMediaQuery.js @@ -1,17 +1,74 @@ -import { useEffect, useState } from 'react'; +import { noop } from '@barso/helpers'; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; -export function useMediaQuery(query) { - const [matches, setMatches] = useState(() => typeof window !== 'undefined' && !!window.matchMedia?.(query)?.matches); +/** + * @typedef {Object} UseMediaQueryOptions + * @property {number} [debounceMs] - Delay in ms before updating the state. + * @property {boolean | (() => boolean)} [fallback=false] - Initial state used during SSR and hydration. + * @property {(matches: boolean) => void} [onChange] - Callback fired when the query match state changes. + */ - useEffect(() => { - const media = window.matchMedia(query); - const listener = () => setMatches(media.matches); +/** + * A robust React hook to monitor media queries, optimized for Next.js and React 18+. + * @param {string} query - The media query string to monitor (e.g., '(max-width: 768px)'). + * @param {UseMediaQueryOptions} [options] - Optional configuration for debounce, SSR fallback, and change events. + * @returns {boolean} - Returns true if the media query matches, false otherwise. + */ +export function useMediaQuery(query, { debounceMs, fallback = false, onChange } = {}) { + const getServerSnapshot = () => (typeof fallback === 'function' ? fallback() : fallback); - listener(); + const mql = useMemo(() => { + if (typeof window === 'undefined' || !window.matchMedia) { + return { matches: getServerSnapshot(), isFallback: true }; + } - media.addEventListener('change', listener); - return () => media.removeEventListener('change', listener); + return window.matchMedia(query); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [query]); + const timeoutRef = useRef(); + + const subscribe = useCallback( + (notify) => { + if (mql.isFallback) return noop; + + const handleChange = () => { + if (!debounceMs) return notify(); + + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(notify, debounceMs); + }; + + if (!mql.addEventListener) { + mql.addListener?.(handleChange); + return () => { + mql.removeListener?.(handleChange); + clearTimeout(timeoutRef.current); + }; + } + + mql.addEventListener('change', handleChange); + + return () => { + mql.removeEventListener('change', handleChange); + clearTimeout(timeoutRef.current); + }; + }, + [debounceMs, mql], + ); + + const getSnapshot = () => mql.matches; + + const matches = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const lastValueRef = useRef(matches); + + useEffect(() => { + if (typeof onChange !== 'function' || matches === lastValueRef.current) return; + + lastValueRef.current = matches; + onChange(matches); + }, [matches, onChange]); + return matches; } diff --git a/packages/hooks/src/useMediaQuery/useMediaQuery.test.js b/packages/hooks/src/useMediaQuery/useMediaQuery.test.js index 58aa7c3a..d0f2b2a8 100644 --- a/packages/hooks/src/useMediaQuery/useMediaQuery.test.js +++ b/packages/hooks/src/useMediaQuery/useMediaQuery.test.js @@ -20,12 +20,13 @@ describe('useMediaQuery', () => { }); beforeEach(() => { + vi.useFakeTimers(); matches = false; listeners.clear(); }); afterAll(() => { - window.matchMedia.mockRestore(); + vi.restoreAllMocks(); }); it('should return true if the media query matches', () => { @@ -47,14 +48,151 @@ describe('useMediaQuery', () => { matches = true; listeners.forEach((cb) => cb()); }); + + expect(result.current).toBe(true); + }); + + it('should handle debounce correctly', async () => { + const { result } = renderHook(() => useMediaQuery('(max-width: 768px)', { debounceMs: 100 })); + + act(() => { + matches = true; + listeners.forEach((cb) => cb()); + }); + + expect(result.current).toBe(false); + await act(() => vi.advanceTimersByTimeAsync(100)); expect(result.current).toBe(true); }); + + it('should call onChange when media query changes', () => { + const onChange = vi.fn(); + + renderHook(() => useMediaQuery('(max-width: 768px)', { onChange })); + + act(() => { + matches = true; + listeners.forEach((cb) => cb()); + }); + + expect(onChange).toHaveBeenCalledExactlyOnceWith(true); + }); + + it('should cleanup listeners on unmount', () => { + const { unmount } = renderHook(() => useMediaQuery('(min-width: 800px)')); + expect(listeners.size).toBe(1); + + unmount(); + expect(listeners.size).toBe(0); + }); + + it('should cleanup debounce timers on unmount', () => { + const { unmount } = renderHook(() => useMediaQuery('(max-width: 768px)', { debounceMs: 100 })); + + act(() => { + matches = true; + listeners.forEach((cb) => cb()); + }); + expect(vi.getTimerCount()).toBe(1); + + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + + describe('legacy addListener/removeListener support', () => { + beforeAll(() => { + window.matchMedia.mockImplementation((query) => ({ + get matches() { + return matches; + }, + media: query, + addListener: (cb) => listeners.add(cb), + removeListener: (cb) => listeners.delete(cb), + })); + }); + + it('should matches and update correctly using legacy methods', () => { + const { result } = renderHook(() => useMediaQuery('(min-width: 900px)')); + expect(result.current).toBe(false); + + act(() => { + matches = true; + listeners.forEach((cb) => cb()); + }); + expect(result.current).toBe(true); + }); + + it('should cleanup listeners on unmount', () => { + const { unmount } = renderHook(() => useMediaQuery('(min-width: 800px)')); + expect(listeners.size).toBe(1); + + unmount(); + expect(listeners.size).toBe(0); + }); + + it('should cleanup debounce timers on unmount', () => { + const { unmount } = renderHook(() => useMediaQuery('(max-width: 768px)', { debounceMs: 100 })); + + act(() => { + matches = true; + listeners.forEach((cb) => cb()); + }); + expect(vi.getTimerCount()).toBe(1); + + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + }); }); describe('SSR', () => { - it('should not throw during SSR and return false', () => { + let originalWindow; + + beforeAll(() => { + originalWindow = global.window; + delete global.window; + }); + + afterAll(() => { + global.window = originalWindow; + }); + + it('should default to false when no fallback is provided', () => { const TestComponent = () => String(useMediaQuery('(min-width: 600px)')); expect(renderToString()).toBe('false'); }); + + it('should use the constant boolean provided as fallback', () => { + const TestComponent = () => String(useMediaQuery('(max-width: 768px)', { fallback: true })); + expect(renderToString()).toBe('true'); + }); + + it.each([true, false])('should execute the fallback function and use its return value (%s)', (fallbackValue) => { + const TestComponent = () => String(useMediaQuery('(max-width: 768px)', { fallback: () => fallbackValue })); + expect(renderToString()).toBe(String(fallbackValue)); + }); + }); + + describe('when window.matchMedia is not available', () => { + let originalMatchMedia; + + beforeAll(() => { + originalMatchMedia = window.matchMedia; + delete window.matchMedia; + }); + + afterAll(() => { + window.matchMedia = originalMatchMedia; + }); + + it('should return false when no fallback is provided', () => { + const { result } = renderHook(() => useMediaQuery('(min-width: 600px)')); + expect(result.current).toBe(false); + }); + + it('should return the fallback value', () => { + const { result } = renderHook(() => useMediaQuery('(min-width: 600px)', { fallback: true })); + expect(result.current).toBe(true); + }); }); });