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);
+ });
});
});