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 1 commit
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');
});
});