Repository: streamich/react-use
Pull request #684 (2019-10-16 11:33:03)

feat: useRafState
`useState` hook that only updates state after `requestAnimationFrame`. Can be reused in existing hooks in this library. Has the same signature as React's `useState`.


Patch:
diff --git a/README.md b/README.md
index ebe6c734..81e66d32 100644
--- a/README.md
+++ b/README.md
@@ -128,6 +128,7 @@
   - [`useGetSetState`](./docs/useGetSetState.md) &mdash; as if [`useGetSet`](./docs/useGetSet.md) and [`useSetState`](./docs/useSetState.md) had a baby.
   - [`usePrevious`](./docs/usePrevious.md) &mdash; returns the previous state or props. [![][img-demo]](https://codesandbox.io/s/fervent-galileo-krgx6)
   - [`useObservable`](./docs/useObservable.md) &mdash; tracks latest value of an `Observable`.
+  - [`useRafState`](./docs/useRafState.md) &mdash; 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) &mdash; creates `setState` method which works like `this.setState`. [![][img-demo]](https://codesandbox.io/s/n75zqn1xp0)
   - [`useStateList`](./docs/useStateList.md) &mdash; circularly iterates over an array. [![][img-demo]](https://codesandbox.io/s/bold-dewdney-pjzkd)
   - [`useToggle` and `useBoolean`](./docs/useToggle.md) &mdash; tracks state of a boolean. [![][img-demo]](https://codesandbox.io/s/focused-sammet-brw2d)
diff --git a/docs/useRafState.md b/docs/useRafState.md
new file mode 100644
index 00000000..7740724b
--- /dev/null
+++ b/docs/useRafState.md
@@ -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>;
+};
+```
diff --git a/src/__stories__/useRafState.story.tsx b/src/__stories__/useRafState.story.tsx
new file mode 100644
index 00000000..1b035859
--- /dev/null
+++ b/src/__stories__/useRafState.story.tsx
@@ -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 />);
diff --git a/src/index.ts b/src/index.ts
index 65872134..f1057a30 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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
  */
diff --git a/src/useMouse.ts b/src/useMouse.ts
index 665a126b..3acb6ccc 100644
--- a/src/useMouse.ts
+++ b/src/useMouse.ts
@@ -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;
@@ -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,
@@ -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]);
diff --git a/src/useRafState.ts b/src/useRafState.ts
new file mode 100644
index 00000000..f5732ff8
--- /dev/null
+++ b/src/useRafState.ts
@@ -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;
diff --git a/src/useScroll.ts b/src/useScroll.ts
index caf78f49..f1805783 100644
--- a/src/useScroll.ts
+++ b/src/useScroll.ts
@@ -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;
@@ -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) {
@@ -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);
       }
diff --git a/src/useWindowScroll.ts b/src/useWindowScroll.ts
index 218392ad..82d6be15 100644
--- a/src/useWindowScroll.ts
+++ b/src/useWindowScroll.ts
@@ -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,
       });
     };
 
@@ -30,7 +28,6 @@ const useWindowScroll = (): State => {
     });
 
     return () => {
-      cancelAnimationFrame(frame.current);
       window.removeEventListener('scroll', handler);
     };
   }, []);
diff --git a/src/useWindowSize.ts b/src/useWindowSize.ts
index 3f081a6a..e3021a08 100644
--- a/src/useWindowSize.ts
+++ b/src/useWindowSize.ts
@@ -1,9 +1,10 @@
-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,
   });
@@ -11,21 +12,15 @@ const useWindowSize = (initialWidth = Infinity, initialHeight = Infinity) => {
   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 {


Test patch:
diff --git a/src/__tests__/useRafState.test.ts b/src/__tests__/useRafState.test.ts
new file mode 100644
index 00000000..bbc1a651
--- /dev/null
+++ b/src/__tests__/useRafState.test.ts
@@ -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);
+  });
+});