Skip to content

Commit 00816a4

Browse files
authored
feat: useRafState (#684)
1 parent 1fa1045 commit 00816a4

File tree

10 files changed

+219
-64
lines changed

10 files changed

+219
-64
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
- [`useGetSetState`](./docs/useGetSetState.md) — as if [`useGetSet`](./docs/useGetSet.md) and [`useSetState`](./docs/useSetState.md) had a baby.
129129
- [`usePrevious`](./docs/usePrevious.md) — returns the previous state or props. [![][img-demo]](https://codesandbox.io/s/fervent-galileo-krgx6)
130130
- [`useObservable`](./docs/useObservable.md) — tracks latest value of an `Observable`.
131+
- [`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)
131132
- [`useSetState`](./docs/useSetState.md) — creates `setState` method which works like `this.setState`. [![][img-demo]](https://codesandbox.io/s/n75zqn1xp0)
132133
- [`useStateList`](./docs/useStateList.md) — circularly iterates over an array. [![][img-demo]](https://codesandbox.io/s/bold-dewdney-pjzkd)
133134
- [`useToggle` and `useBoolean`](./docs/useToggle.md) — tracks state of a boolean. [![][img-demo]](https://codesandbox.io/s/focused-sammet-brw2d)

docs/useRafState.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# `useRafState`
2+
3+
React state hook that only updates state in the callback of [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame).
4+
5+
## Usage
6+
7+
```jsx
8+
import {useRafState, useMount} from 'react-use';
9+
10+
const Demo = () => {
11+
const [state, setState] = useRafState({
12+
width: 0,
13+
height: 0,
14+
});
15+
16+
useMount(() => {
17+
const onResize = () => {
18+
setState({
19+
width: window.clientWidth,
20+
height: window.height,
21+
});
22+
};
23+
24+
window.addEventListener('resize', onResize);
25+
26+
return () => {
27+
window.removeEventListener('resize', onResize);
28+
};
29+
});
30+
31+
return <pre>{JSON.stringify(state, null, 2)}</pre>;
32+
};
33+
```

src/__stories__/useRafState.story.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { storiesOf } from '@storybook/react';
2+
import * as React from 'react';
3+
import { useRafState, useMount } from '..';
4+
import ShowDocs from './util/ShowDocs';
5+
6+
const Demo = () => {
7+
const [state, setState] = useRafState({ x: 0, y: 0 });
8+
9+
useMount(() => {
10+
const onMouseMove = (event: MouseEvent) => {
11+
setState({ x: event.clientX, y: event.clientY });
12+
};
13+
const onTouchMove = (event: TouchEvent) => {
14+
setState({ x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY });
15+
};
16+
17+
document.addEventListener('mousemove', onMouseMove);
18+
document.addEventListener('touchmove', onTouchMove);
19+
20+
return () => {
21+
document.removeEventListener('mousemove', onMouseMove);
22+
document.removeEventListener('touchmove', onTouchMove);
23+
};
24+
});
25+
26+
return <pre>{JSON.stringify(state, null, 2)}</pre>;
27+
};
28+
29+
storiesOf('State|useRafState', module)
30+
.add('Docs', () => <ShowDocs md={require('../../docs/useRafState.md')} />)
31+
.add('Demo', () => <Demo />);

src/__tests__/useRafState.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { act, renderHook } from '@testing-library/react-hooks';
2+
import { replaceRaf } from 'raf-stub';
3+
import useRafState from '../useRafState';
4+
5+
interface RequestAnimationFrame {
6+
reset(): void;
7+
step(): void;
8+
}
9+
10+
declare var requestAnimationFrame: RequestAnimationFrame;
11+
12+
replaceRaf();
13+
14+
beforeEach(() => {
15+
requestAnimationFrame.reset();
16+
});
17+
18+
afterEach(() => {
19+
requestAnimationFrame.reset();
20+
});
21+
22+
describe('useRafState', () => {
23+
it('should be defined', () => {
24+
expect(useRafState).toBeDefined();
25+
});
26+
27+
it('should only update state after requestAnimationFrame when providing an object', () => {
28+
const { result } = renderHook(() => useRafState(0));
29+
30+
act(() => {
31+
result.current[1](1);
32+
});
33+
expect(result.current[0]).toBe(0);
34+
35+
act(() => {
36+
requestAnimationFrame.step();
37+
});
38+
expect(result.current[0]).toBe(1);
39+
40+
act(() => {
41+
result.current[1](2);
42+
requestAnimationFrame.step();
43+
});
44+
expect(result.current[0]).toBe(2);
45+
46+
act(() => {
47+
result.current[1](prevState => prevState * 2);
48+
requestAnimationFrame.step();
49+
});
50+
expect(result.current[0]).toBe(4);
51+
});
52+
53+
it('should only update state after requestAnimationFrame when providing a function', () => {
54+
const { result } = renderHook(() => useRafState(0));
55+
56+
act(() => {
57+
result.current[1](prevState => prevState + 1);
58+
});
59+
expect(result.current[0]).toBe(0);
60+
61+
act(() => {
62+
requestAnimationFrame.step();
63+
});
64+
expect(result.current[0]).toBe(1);
65+
66+
act(() => {
67+
result.current[1](prevState => prevState * 3);
68+
requestAnimationFrame.step();
69+
});
70+
expect(result.current[0]).toBe(3);
71+
});
72+
73+
it('should cancel update state on unmount', () => {
74+
const { unmount } = renderHook(() => useRafState(0));
75+
const spyRafCancel = jest.spyOn(global, 'cancelAnimationFrame' as any);
76+
77+
expect(spyRafCancel).not.toHaveBeenCalled();
78+
79+
unmount();
80+
81+
expect(spyRafCancel).toHaveBeenCalledTimes(1);
82+
});
83+
});

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export { default as usePreviousDistinct } from './usePreviousDistinct';
6060
export { default as usePromise } from './usePromise';
6161
export { default as useRaf } from './useRaf';
6262
export { default as useRafLoop } from './useRafLoop';
63+
export { default as useRafState } from './useRafState';
64+
6365
/**
6466
* @deprecated This hook is obsolete, use `useMountedState` instead
6567
*/

src/useMouse.ts

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { RefObject, useEffect, useRef, useState } from 'react';
1+
import { RefObject, useEffect } from 'react';
2+
3+
import useRafState from './useRafState';
24

35
export interface State {
46
docX: number;
@@ -18,8 +20,7 @@ const useMouse = (ref: RefObject<Element>): State => {
1820
}
1921
}
2022

21-
const frame = useRef(0);
22-
const [state, setState] = useState<State>({
23+
const [state, setState] = useRafState<State>({
2324
docX: 0,
2425
docY: 0,
2526
posX: 0,
@@ -32,34 +33,29 @@ const useMouse = (ref: RefObject<Element>): State => {
3233

3334
useEffect(() => {
3435
const moveHandler = (event: MouseEvent) => {
35-
cancelAnimationFrame(frame.current);
36-
37-
frame.current = requestAnimationFrame(() => {
38-
if (ref && ref.current) {
39-
const { left, top, width: elW, height: elH } = ref.current.getBoundingClientRect();
40-
const posX = left + window.pageXOffset;
41-
const posY = top + window.pageYOffset;
42-
const elX = event.pageX - posX;
43-
const elY = event.pageY - posY;
36+
if (ref && ref.current) {
37+
const { left, top, width: elW, height: elH } = ref.current.getBoundingClientRect();
38+
const posX = left + window.pageXOffset;
39+
const posY = top + window.pageYOffset;
40+
const elX = event.pageX - posX;
41+
const elY = event.pageY - posY;
4442

45-
setState({
46-
docX: event.pageX,
47-
docY: event.pageY,
48-
posX,
49-
posY,
50-
elX,
51-
elY,
52-
elH,
53-
elW,
54-
});
55-
}
56-
});
43+
setState({
44+
docX: event.pageX,
45+
docY: event.pageY,
46+
posX,
47+
posY,
48+
elX,
49+
elY,
50+
elH,
51+
elW,
52+
});
53+
}
5754
};
5855

5956
document.addEventListener('mousemove', moveHandler);
6057

6158
return () => {
62-
cancelAnimationFrame(frame.current);
6359
document.removeEventListener('mousemove', moveHandler);
6460
};
6561
}, [ref]);

src/useRafState.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useRef, useState, useCallback, Dispatch, SetStateAction } from 'react';
2+
3+
import useUnmount from './useUnmount';
4+
5+
const useRafState = <S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] => {
6+
const frame = useRef(0);
7+
const [state, setState] = useState(initialState);
8+
9+
const setRafState = useCallback((value: S | ((prevState: S) => S)) => {
10+
cancelAnimationFrame(frame.current);
11+
12+
frame.current = requestAnimationFrame(() => {
13+
setState(value);
14+
});
15+
}, []);
16+
17+
useUnmount(() => {
18+
cancelAnimationFrame(frame.current);
19+
});
20+
21+
return [state, setRafState];
22+
};
23+
24+
export default useRafState;

src/useScroll.ts

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { RefObject, useEffect, useRef, useState } from 'react';
1+
import { RefObject, useEffect } from 'react';
2+
3+
import useRafState from './useRafState';
24

35
export interface State {
46
x: number;
@@ -12,24 +14,19 @@ const useScroll = (ref: RefObject<HTMLElement>): State => {
1214
}
1315
}
1416

15-
const frame = useRef(0);
16-
const [state, setState] = useState<State>({
17+
const [state, setState] = useRafState<State>({
1718
x: 0,
1819
y: 0,
1920
});
2021

2122
useEffect(() => {
2223
const handler = () => {
23-
cancelAnimationFrame(frame.current);
24-
25-
frame.current = requestAnimationFrame(() => {
26-
if (ref.current) {
27-
setState({
28-
x: ref.current.scrollLeft,
29-
y: ref.current.scrollTop,
30-
});
31-
}
32-
});
24+
if (ref.current) {
25+
setState({
26+
x: ref.current.scrollLeft,
27+
y: ref.current.scrollTop,
28+
});
29+
}
3330
};
3431

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

4239
return () => {
43-
if (frame.current) {
44-
cancelAnimationFrame(frame.current);
45-
}
46-
4740
if (ref.current) {
4841
ref.current.removeEventListener('scroll', handler);
4942
}

src/useWindowScroll.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
1-
import { useEffect, useRef, useState } from 'react';
1+
import { useEffect } from 'react';
22
import { isClient } from './util';
33

4+
import useRafState from './useRafState';
5+
46
export interface State {
57
x: number;
68
y: number;
79
}
810

911
const useWindowScroll = (): State => {
10-
const frame = useRef(0);
11-
const [state, setState] = useState<State>({
12+
const [state, setState] = useRafState<State>({
1213
x: isClient ? window.pageXOffset : 0,
1314
y: isClient ? window.pageYOffset : 0,
1415
});
1516

1617
useEffect(() => {
1718
const handler = () => {
18-
cancelAnimationFrame(frame.current);
19-
frame.current = requestAnimationFrame(() => {
20-
setState({
21-
x: window.pageXOffset,
22-
y: window.pageYOffset,
23-
});
19+
setState({
20+
x: window.pageXOffset,
21+
y: window.pageYOffset,
2422
});
2523
};
2624

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

3230
return () => {
33-
cancelAnimationFrame(frame.current);
3431
window.removeEventListener('scroll', handler);
3532
};
3633
}, []);

src/useWindowSize.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,26 @@
1-
import { useRef, useEffect, useState } from 'react';
1+
import { useEffect } from 'react';
2+
3+
import useRafState from './useRafState';
24
import { isClient } from './util';
35

46
const useWindowSize = (initialWidth = Infinity, initialHeight = Infinity) => {
5-
const frame = useRef(0);
6-
const [state, setState] = useState<{ width: number; height: number }>({
7+
const [state, setState] = useRafState<{ width: number; height: number }>({
78
width: isClient ? window.innerWidth : initialWidth,
89
height: isClient ? window.innerHeight : initialHeight,
910
});
1011

1112
useEffect(() => {
1213
if (isClient) {
1314
const handler = () => {
14-
cancelAnimationFrame(frame.current);
15-
16-
frame.current = requestAnimationFrame(() => {
17-
setState({
18-
width: window.innerWidth,
19-
height: window.innerHeight,
20-
});
15+
setState({
16+
width: window.innerWidth,
17+
height: window.innerHeight,
2118
});
2219
};
2320

2421
window.addEventListener('resize', handler);
2522

2623
return () => {
27-
cancelAnimationFrame(frame.current);
28-
2924
window.removeEventListener('resize', handler);
3025
};
3126
} else {

0 commit comments

Comments
 (0)