Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: useMedia SSR hydration bug with defaultState #2216

Merged
merged 2 commits into from
Dec 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/useMedia.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ useMedia(query: string, defaultState: boolean = false): boolean;
```

The `defaultState` parameter is only used as a fallback for server side rendering.

When server side rendering, it is important to set this parameter because without it the server's initial state will fallback to false, but the client will initialize to the result of the media query. When React hydrates the server render, it may not match the client's state. See the [React docs](https://reactjs.org/docs/react-dom.html#hydrate) for more on why this is can lead to costly bugs 🐛.
26 changes: 22 additions & 4 deletions src/useMedia.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { useEffect, useState } from 'react';
import { isBrowser } from './misc/util';

const useMedia = (query: string, defaultState: boolean = false) => {
const [state, setState] = useState(
isBrowser ? () => window.matchMedia(query).matches : defaultState
);
const getInitialState = (query: string, defaultState?: boolean) => {
// Prevent a React hydration mismatch when a default value is provided by not defaulting to window.matchMedia(query).matches.
if (defaultState !== undefined) {
return defaultState;
}

if (isBrowser) {
return window.matchMedia(query).matches;
}

// A default value has not been provided, and you are rendering on the server, warn of a possible hydration mismatch when defaulting to false.
if (process.env.NODE_ENV !== 'production') {
console.warn(
'`useMedia` When server side rendering, defaultState should be defined to prevent a hydration mismatches.'
);
}

return false;
};

const useMedia = (query: string, defaultState?: boolean) => {
const [state, setState] = useState(getInitialState(query, defaultState));

useEffect(() => {
let mounted = true;
Expand Down
42 changes: 42 additions & 0 deletions tests/useMedia.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { renderHook } from '@testing-library/react-hooks';
import { renderHook as renderHookSSR } from '@testing-library/react-hooks/server';
import { useMedia } from '../src';

const createMockMediaMatcher = (matches: Record<string, boolean>) => (qs: string) => ({
matches: matches[qs] ?? false,
addListener: () => {},
removeListener: () => {},
});

describe('useMedia', () => {
beforeEach(() => {
window.matchMedia = createMockMediaMatcher({
'(min-width: 500px)': true,
'(min-width: 1000px)': false,
}) as any;
});
it('should return true if media query matches', () => {
const { result } = renderHook(() => useMedia('(min-width: 500px)'));
expect(result.current).toBe(true);
});
it('should return false if media query does not match', () => {
const { result } = renderHook(() => useMedia('(min-width: 1200px)'));
expect(result.current).toBe(false);
});
it('should return default state before hydration', () => {
const { result } = renderHookSSR(() => useMedia('(min-width: 500px)', false));
expect(result.current).toBe(false);
});
it('should return media query result after hydration', async () => {
const { result, hydrate } = renderHookSSR(() => useMedia('(min-width: 500px)', false));
expect(result.current).toBe(false);
hydrate();
expect(result.current).toBe(true);
});
it('should return media query result after hydration', async () => {
const { result, hydrate } = renderHookSSR(() => useMedia('(min-width: 1200px)', true));
expect(result.current).toBe(true);
hydrate();
expect(result.current).toBe(false);
});
});