Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

React hooks - add useMedia #1364

Merged
merged 3 commits into from
Apr 14, 2020
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/react-hooks/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

<!-- ## [Unreleased] -->
## [Unreleased]

- Added a `useMedia` hook ([#1364](https://github.com/Shopify/quilt/pull/1364))

## [1.7.0] - 2020-04-08

Expand Down
15 changes: 15 additions & 0 deletions packages/react-hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,21 @@ function MyComponent() {
}
```

### `useMedia()`

This hook will listen to a [MediaQueryList](https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList) created via [matchMedia](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia) and return true or false if it matches the media query string.

```tsx
function MyComponent() {
const isSmallScreen = useMedia('(max-width: 640px)');
return (
<p>
{isSmallScreen ? 'This is a small screen' : 'This is not a small screen'}
</p>
);
}
```

### `useMountedRef()`

This hook keeps track of a component's mounted / un-mounted status and returns a ref object like React’s [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref) with a boolean value representing said status. This is often used when a component contains an async task that sets state after the task has resolved.
Expand Down
1 change: 1 addition & 0 deletions packages/react-hooks/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {useDebouncedValue} from './debounced';
export {useInterval} from './interval';
export {useLazyRef} from './lazy-ref';
export {useMedia} from './media';
export {useMountedRef} from './mounted-ref';
export {useOnValueChange} from './on-value-change';
export {usePrevious} from './previous';
Expand Down
23 changes: 23 additions & 0 deletions packages/react-hooks/src/hooks/media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {useState, useEffect} from 'react';

export function useMedia(query: string) {
const [match, setMatch] = useState(false);

useEffect(() => {
if (!window || !window.matchMedia) {
return;
}

const matchMedia = window.matchMedia(query);
const updateMatch = (event: MediaQueryListEvent) => setMatch(event.matches);

setMatch(matchMedia.matches);

matchMedia.addListener(updateMatch);
return () => {
matchMedia.removeListener(updateMatch);
};
}, [query]);

return match;
}
104 changes: 104 additions & 0 deletions packages/react-hooks/src/hooks/tests/media.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React from 'react';
import {matchMedia, mediaQueryList} from '@shopify/jest-dom-mocks';
import {mount} from '@shopify/react-testing';

import {useMedia} from '../media';

function MockComponent({mediaQuery}: {mediaQuery: string}) {
const matchedQuery = useMedia(mediaQuery);
const message = matchedQuery ? 'matched' : 'did not match';
return <div>{message}</div>;
}

describe('useMedia', () => {
beforeEach(() => {
matchMedia.mock();
});

afterEach(() => {
matchMedia.restore();
});

it('installs/uninstalls listeners', () => {
const media = mediaQueryList({
matches: true,
});
const mediaAddSpy = jest.spyOn(media, 'addListener');
const mediaRemoveSpy = jest.spyOn(media, 'removeListener');

matchMedia.setMedia(() => media);

const mockComponent = mount(<MockComponent mediaQuery="print" />);
expect(mediaAddSpy).toHaveBeenCalled();

mockComponent.unmount();
expect(mediaRemoveSpy).toHaveBeenCalled();
});

it('installs new listeners when mediaQuery used by hook changes', () => {
const media = mediaQueryList({
matches: true,
});
const mediaAddSpy = jest.spyOn(media, 'addListener');
const mediaRemoveSpy = jest.spyOn(media, 'removeListener');

matchMedia.setMedia(() => media);

const mockComponent = mount(<MockComponent mediaQuery="print" />);
expect(mediaAddSpy).toHaveBeenCalled();
expect(mediaRemoveSpy).not.toHaveBeenCalled();

mockComponent.setProps({mediaQuery: 'screen'});

expect(mediaRemoveSpy).toHaveBeenCalled();
expect(mediaAddSpy).toHaveBeenCalledTimes(2);
});

it('initial render when matches', () => {
matchMedia.setMedia(() =>
mediaQueryList({
matches: true,
}),
);

const mockComponent = mount(<MockComponent mediaQuery="print" />);
expect(mockComponent.text()).toContain('matched');
});

it('initial render when does not match', () => {
matchMedia.setMedia(() =>
mediaQueryList({
matches: false,
}),
);

const mockComponent = mount(<MockComponent mediaQuery="print" />);
expect(mockComponent.text()).toContain('did not match');
});

it('rerenders when the media changes from !match=>match', () => {
const media = mediaQueryList({
matches: false,
});
const addListenerSpy = jest.spyOn(media, 'addListener');
matchMedia.setMedia(() => media);

const mockComponent = mount(<MockComponent mediaQuery="print" />);
expect(mockComponent.text()).toContain('did not match');

expect(addListenerSpy).toHaveBeenCalled();
mockComponent.act(() => {
matchMedia.setMedia(() =>
mediaQueryList({
matches: true,
}),
);

// setMedia API does not actually invoke the listeners registered by the hook, so we must invoke manually
const [listener] = addListenerSpy.mock.calls[0];
(listener as any)({...media, matches: true});
});

expect(mockComponent.text()).toContain('matched');
});
});