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

feat: useRafState #684

Merged
merged 5 commits into from
Oct 16, 2019
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
- [`useGetSetState`](./docs/useGetSetState.md) — as if [`useGetSet`](./docs/useGetSet.md) and [`useSetState`](./docs/useSetState.md) had a baby.
- [`usePrevious`](./docs/usePrevious.md) — returns the previous state or props. [![][img-demo]](https://codesandbox.io/s/fervent-galileo-krgx6)
- [`useObservable`](./docs/useObservable.md) — tracks latest value of an `Observable`.
- [`useRafState`](./docs/useRafState.md) — creates `setState` method which only updates after `requestAnimationFrame`. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-userafstate--demo)
- [`useSetState`](./docs/useSetState.md) — creates `setState` method which works like `this.setState`. [![][img-demo]](https://codesandbox.io/s/n75zqn1xp0)
- [`useStateList`](./docs/useStateList.md) — circularly iterates over an array. [![][img-demo]](https://codesandbox.io/s/bold-dewdney-pjzkd)
- [`useToggle` and `useBoolean`](./docs/useToggle.md) — tracks state of a boolean. [![][img-demo]](https://codesandbox.io/s/focused-sammet-brw2d)
Expand Down
33 changes: 33 additions & 0 deletions docs/useRafState.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# `useRafState`

React state hook that only updates state in the callback of [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame).

## Usage

```jsx
import {useRafState, useMount} from 'react-use';

const Demo = () => {
const [state, setState] = useRafState({
width: 0,
height: 0,
});

useMount(() => {
const onResize = () => {
setState({
width: window.clientWidth,
height: window.height,
});
};

window.addEventListener('resize', onResize);

return () => {
window.removeEventListener('resize', onResize);
};
});

return <pre>{JSON.stringify(state, null, 2)}</pre>;
};
```
31 changes: 31 additions & 0 deletions src/__stories__/useRafState.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import { useRafState, useMount } from '..';
import ShowDocs from './util/ShowDocs';

const Demo = () => {
const [state, setState] = useRafState({ x: 0, y: 0 });

useMount(() => {
const onMouseMove = (event: MouseEvent) => {
setState({ x: event.clientX, y: event.clientY });
};
const onTouchMove = (event: TouchEvent) => {
setState({ x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY });
};

document.addEventListener('mousemove', onMouseMove);
document.addEventListener('touchmove', onTouchMove);

return () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('touchmove', onTouchMove);
};
});

return <pre>{JSON.stringify(state, null, 2)}</pre>;
};

storiesOf('State|useRafState', module)
.add('Docs', () => <ShowDocs md={require('../../docs/useRafState.md')} />)
.add('Demo', () => <Demo />);
83 changes: 83 additions & 0 deletions src/__tests__/useRafState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { act, renderHook } from '@testing-library/react-hooks';
import { replaceRaf } from 'raf-stub';
import useRafState from '../useRafState';

interface RequestAnimationFrame {
reset(): void;
step(): void;
}

declare var requestAnimationFrame: RequestAnimationFrame;

replaceRaf();

beforeEach(() => {
requestAnimationFrame.reset();
});

afterEach(() => {
requestAnimationFrame.reset();
});

describe('useRafState', () => {
it('should be defined', () => {
expect(useRafState).toBeDefined();
});

it('should only update state after requestAnimationFrame when providing an object', () => {
const { result } = renderHook(() => useRafState(0));

act(() => {
result.current[1](1);
});
expect(result.current[0]).toBe(0);

act(() => {
requestAnimationFrame.step();
});
expect(result.current[0]).toBe(1);

act(() => {
result.current[1](2);
requestAnimationFrame.step();
});
expect(result.current[0]).toBe(2);

act(() => {
result.current[1](prevState => prevState * 2);
requestAnimationFrame.step();
});
expect(result.current[0]).toBe(4);
});

it('should only update state after requestAnimationFrame when providing a function', () => {
const { result } = renderHook(() => useRafState(0));

act(() => {
result.current[1](prevState => prevState + 1);
});
expect(result.current[0]).toBe(0);

act(() => {
requestAnimationFrame.step();
});
expect(result.current[0]).toBe(1);

act(() => {
result.current[1](prevState => prevState * 3);
requestAnimationFrame.step();
});
expect(result.current[0]).toBe(3);
});

it('should cancel update state on unmount', () => {
const { unmount } = renderHook(() => useRafState(0));
const spyRafCancel = jest.spyOn(global, 'cancelAnimationFrame' as any);

expect(spyRafCancel).not.toHaveBeenCalled();

unmount();

expect(spyRafCancel).toHaveBeenCalledTimes(1);
});
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export { default as usePreviousDistinct } from './usePreviousDistinct';
export { default as usePromise } from './usePromise';
export { default as useRaf } from './useRaf';
export { default as useRafLoop } from './useRafLoop';
export { default as useRafState } from './useRafState';

/**
* @deprecated This hook is obsolete, use `useMountedState` instead
*/
Expand Down
46 changes: 21 additions & 25 deletions src/useMouse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { RefObject, useEffect, useRef, useState } from 'react';
import { RefObject, useEffect } from 'react';

import useRafState from './useRafState';

export interface State {
docX: number;
Expand All @@ -18,8 +20,7 @@ const useMouse = (ref: RefObject<Element>): State => {
}
}

const frame = useRef(0);
const [state, setState] = useState<State>({
const [state, setState] = useRafState<State>({
docX: 0,
docY: 0,
posX: 0,
Expand All @@ -32,34 +33,29 @@ const useMouse = (ref: RefObject<Element>): State => {

useEffect(() => {
const moveHandler = (event: MouseEvent) => {
cancelAnimationFrame(frame.current);

frame.current = requestAnimationFrame(() => {
if (ref && ref.current) {
const { left, top, width: elW, height: elH } = ref.current.getBoundingClientRect();
const posX = left + window.pageXOffset;
const posY = top + window.pageYOffset;
const elX = event.pageX - posX;
const elY = event.pageY - posY;
if (ref && ref.current) {
const { left, top, width: elW, height: elH } = ref.current.getBoundingClientRect();
const posX = left + window.pageXOffset;
const posY = top + window.pageYOffset;
const elX = event.pageX - posX;
const elY = event.pageY - posY;

setState({
docX: event.pageX,
docY: event.pageY,
posX,
posY,
elX,
elY,
elH,
elW,
});
}
});
setState({
docX: event.pageX,
docY: event.pageY,
posX,
posY,
elX,
elY,
elH,
elW,
});
}
};

document.addEventListener('mousemove', moveHandler);

return () => {
cancelAnimationFrame(frame.current);
document.removeEventListener('mousemove', moveHandler);
};
}, [ref]);
Expand Down
24 changes: 24 additions & 0 deletions src/useRafState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useRef, useState, useCallback, Dispatch, SetStateAction } from 'react';

import useUnmount from './useUnmount';

const useRafState = <S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] => {
const frame = useRef(0);
const [state, setState] = useState(initialState);

const setRafState = useCallback((value: S | ((prevState: S) => S)) => {
cancelAnimationFrame(frame.current);

frame.current = requestAnimationFrame(() => {
setState(value);
});
}, []);

useUnmount(() => {
cancelAnimationFrame(frame.current);
});

return [state, setRafState];
};

export default useRafState;
27 changes: 10 additions & 17 deletions src/useScroll.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { RefObject, useEffect, useRef, useState } from 'react';
import { RefObject, useEffect } from 'react';

import useRafState from './useRafState';

export interface State {
x: number;
Expand All @@ -12,24 +14,19 @@ const useScroll = (ref: RefObject<HTMLElement>): State => {
}
}

const frame = useRef(0);
const [state, setState] = useState<State>({
const [state, setState] = useRafState<State>({
x: 0,
y: 0,
});

useEffect(() => {
const handler = () => {
cancelAnimationFrame(frame.current);

frame.current = requestAnimationFrame(() => {
if (ref.current) {
setState({
x: ref.current.scrollLeft,
y: ref.current.scrollTop,
});
}
});
if (ref.current) {
setState({
x: ref.current.scrollLeft,
y: ref.current.scrollTop,
});
}
};

if (ref.current) {
Expand All @@ -40,10 +37,6 @@ const useScroll = (ref: RefObject<HTMLElement>): State => {
}

return () => {
if (frame.current) {
cancelAnimationFrame(frame.current);
}

if (ref.current) {
ref.current.removeEventListener('scroll', handler);
}
Expand Down
17 changes: 7 additions & 10 deletions src/useWindowScroll.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect } from 'react';
import { isClient } from './util';

import useRafState from './useRafState';

export interface State {
x: number;
y: number;
}

const useWindowScroll = (): State => {
const frame = useRef(0);
const [state, setState] = useState<State>({
const [state, setState] = useRafState<State>({
x: isClient ? window.pageXOffset : 0,
y: isClient ? window.pageYOffset : 0,
});

useEffect(() => {
const handler = () => {
cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
setState({
x: window.pageXOffset,
y: window.pageYOffset,
});
setState({
x: window.pageXOffset,
y: window.pageYOffset,
});
};

Expand All @@ -30,7 +28,6 @@ const useWindowScroll = (): State => {
});

return () => {
cancelAnimationFrame(frame.current);
window.removeEventListener('scroll', handler);
};
}, []);
Expand Down
19 changes: 7 additions & 12 deletions src/useWindowSize.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,26 @@
import { useRef, useEffect, useState } from 'react';
import { useEffect } from 'react';

import useRafState from './useRafState';
import { isClient } from './util';

const useWindowSize = (initialWidth = Infinity, initialHeight = Infinity) => {
const frame = useRef(0);
const [state, setState] = useState<{ width: number; height: number }>({
const [state, setState] = useRafState<{ width: number; height: number }>({
width: isClient ? window.innerWidth : initialWidth,
height: isClient ? window.innerHeight : initialHeight,
});

useEffect(() => {
if (isClient) {
const handler = () => {
cancelAnimationFrame(frame.current);

frame.current = requestAnimationFrame(() => {
setState({
width: window.innerWidth,
height: window.innerHeight,
});
setState({
width: window.innerWidth,
height: window.innerHeight,
});
};

window.addEventListener('resize', handler);

return () => {
cancelAnimationFrame(frame.current);

window.removeEventListener('resize', handler);
};
} else {
Expand Down