Skip to content

Commit

Permalink
feat: useRafState (#684)
Browse files Browse the repository at this point in the history
  • Loading branch information
wardoost authored Oct 16, 2019
1 parent 1fa1045 commit 00816a4
Show file tree
Hide file tree
Showing 10 changed files with 219 additions and 64 deletions.
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

0 comments on commit 00816a4

Please sign in to comment.