diff --git a/.circleci/config.yml b/.circleci/config.yml index 4f2c037c..1dd2e5f6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2 refs: container: &container docker: - - image: node:12.9.1 + - image: node:12.12.0 working_directory: ~/repo steps: - &Versions diff --git a/CHANGELOG.md b/CHANGELOG.md index 61180767..78784e12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [12.6.0](https://github.com/streamich/react-use/compare/v12.5.0...v12.6.0) (2019-10-16) + + +### Features + +* useRafState ([#684](https://github.com/streamich/react-use/issues/684)) ([00816a4](https://github.com/streamich/react-use/commit/00816a4)) + # [12.5.0](https://github.com/streamich/react-use/compare/v12.4.0...v12.5.0) (2019-10-13) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..16642432 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing + +Thanks for being willing to contribute 🙌 If you contribute to this project, you agree to release your work under the license of this project. + +**Working on your first Pull Request?** You can learn how from this [First Contributions](https://github.com/firstcontributions/first-contributions) guide. + +## Project setup + +1. Fork and clone the repo +1. Run `yarn install` to install dependencies +1. Create a branch for your PR with `git checkout -b pr/your-branch-name` + +> Tip: Keep your `master` branch pointing at the original repository and make +> pull requests from branches on your fork. To do this, run: +> +> ```sh +> git remote add upstream https://github.com/streamich/react-use.git +> git fetch upstream +> git branch --set-upstream-to=upstream/master master +> ``` +> +> This will add the original repository as a "remote" called "upstream," Then +> fetch the git information from that remote, then set your local `master` +> branch to use the upstream master branch whenever you run `git pull`. Then you +> can make all of your pull request branches based on this `master` branch. +> Whenever you want to update your version of `master`, do a regular `git pull`. + +## Development + +This library is a collection of React hooks so a proposal for a new hook will need to utilize the [React Hooks API](https://reactjs.org/docs/hooks-reference.html) internally to be taken into consideration. + +### Creating a new hook + +1. Create `src/useYourHookName.ts` and `src/__stories__/useYourHookName.story.tsx`, run `yarn start` to start the storybook development server and start coding your hook +1. Create `src/__tests__/useYourHookName.test.ts`, run `yarn test:watch` to start the test runner in watch mode and start writing tests for your hook +1. Create `src/docs/useYourHookName.md` and create documentation for your hook +1. Export your hook from `src/index.ts` and add your hook to `README.md` + +You can also write your tests first if you prefer [test-driven development](https://en.wikipedia.org/wiki/Test-driven_development). + +### Updating an existing hook + +1. Run `yarn start` to start the storybook development server and start applying changes +2. Update tests according to your changes using `yarn test:watch` +3. Update documentation according to your changes + +## Committing and Pushing changes + +### Commit messages + +This repo uses [semantic-release](https://github.com/semantic-release/semantic-release) and [conventional commit messages](https://conventionalcommits.org) so prefix your commits with `fix:` or `feat:` if you want your changes to appear in [release notes](https://github.com/streamich/react-use/blob/master/CHANGELOG.md). + +### Git hooks + +There are git hooks set up with this project that are automatically enabled +when you install dependencies. These hooks automatically test and validate your code when creating commits. They're really handy but can be temporarily disabled by adding a `--no-verify` flag to your commit command. This is useful when you want to commit and push to get feedback on uncompleted code. + +## Help needed + +Please have a look at the [open issues](https://github.com/streamich/react-use/issues) and respond to questions, bug reports and feature requests. Thanks! + +We're also looking to improve the code coverage on this project. To easily know what hooks need tests run `yarn test:coverage` to generate a code coverage report. You can see the report in your terminal or open `coverage/lcov-report/index.html` to see the HTML report. diff --git a/README.md b/README.md index e3b9393c..76fd92c1 100644 --- a/README.md +++ b/README.md @@ -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) @@ -135,6 +136,7 @@ - [`useList`](./docs/useList.md) and [`useUpsert`](./docs/useUpsert.md) — tracks state of an array. [![][img-demo]](https://codesandbox.io/s/wonderful-mahavira-1sm0w) - [`useMap`](./docs/useMap.md) — tracks state of an object. [![][img-demo]](https://codesandbox.io/s/quirky-dewdney-gi161) - [`useStateValidator`](./docs/useStateValidator.md) — tracks state of an object. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usestatevalidator--demo) + - [`useMultiStateValidator`](./docs/useMultiStateValidator.md) — alike the `useStateValidator`, but tracks multiple states at a time. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemultistatevalidator--demo) - [`useMediatedState`](./docs/useMediatedState.md) — like the regular `useState` but with mediation by custom function. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemediatedstate--demo)
diff --git a/docs/useMultiStateValidator.md b/docs/useMultiStateValidator.md new file mode 100644 index 00000000..42de5b1e --- /dev/null +++ b/docs/useMultiStateValidator.md @@ -0,0 +1,55 @@ +# `useMultiStateValidator` + +Each time any of given states changes - validator function is invoked. + +## Usage +```ts +import * as React from 'react'; +import { useMultiStateValidator } from 'react-use'; + +const DemoStateValidator = (s: number[]) => [s.every((num: number) => !(num % 2))] as [boolean]; +const Demo = () => { + const [state1, setState1] = React.useState(1); + const [state2, setState2] = React.useState(1); + const [state3, setState3] = React.useState(1); + const [[isValid]] = useMultiStateValidator([state1, state2, state3], DemoStateValidator); + + return ( +
+
Below fields will be valid if all of them is even
+ ) => { + setState1((ev.target.value as unknown) as number); + }} + /> + ) => { + setState2((ev.target.value as unknown) as number); + }} + /> + ) => { + setState3((ev.target.value as unknown) as number); + }} + /> + {isValid !== null && {isValid ? 'Valid!' : 'Invalid'}} +
+ ); +}; +``` + +## Reference +```ts +const [validity, revalidate] = useStateValidator( + state: any[] | { [p: string]: any } | { [p: number]: any }, + validator: (state, setValidity?)=>[boolean|null, ...any[]], + initialValidity: any = [undefined] +); +``` +- **`state`**_`: any[] | { [p: string]: any } | { [p: number]: any }`_ can be both an array or object. It's _values_ will be used as a deps for inner `useEffect`. +- **`validity`**_`: [boolean|null, ...any[]]`_ result of validity check. First element is strictly nullable boolean, but others can contain arbitrary data; +- **`revalidate`**_`: ()=>void`_ runs validator once again +- **`validator`**_`: (state, setValidity?)=>[boolean|null, ...any[]]`_ should return an array suitable for validity state described above; + - `states` - current states values as the've been passed to the hook; + - `setValidity` - if defined hook will not trigger validity change automatically. Useful for async validators; +- `initialValidity` - validity value which set when validity is nt calculated yet; 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
{JSON.stringify(state, null, 2)}
; +}; +``` diff --git a/package.json b/package.json index 9b3401ce..b502f850 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-use", - "version": "12.5.0", + "version": "12.6.0", "description": "Collection of React Hooks", "main": "lib/index.js", "module": "esm/index.js", @@ -15,6 +15,7 @@ "start": "yarn storybook", "test": "jest", "test:watch": "jest --watch", + "test:coverage": "jest --coverage", "lint": "tslint 'src/**/*.{ts,tsx}' -t verbose", "lint:fix": "yarn lint --fix", "lint:types": "tsc --noEmit", @@ -54,7 +55,8 @@ "screenfull": "^5.0.0", "set-harmonic-interval": "^1.0.0", "throttle-debounce": "^2.0.1", - "ts-easing": "^0.2.0" + "ts-easing": "^0.2.0", + "tslib": "^1.10.0" }, "peerDependencies": { "react": "^16.8.0", @@ -69,19 +71,19 @@ "@semantic-release/changelog": "3.0.4", "@semantic-release/git": "7.0.16", "@semantic-release/npm": "5.1.13", - "@shopify/jest-dom-mocks": "2.8.2", + "@shopify/jest-dom-mocks": "2.8.3", "@storybook/addon-actions": "5.1.11", "@storybook/addon-knobs": "5.1.11", "@storybook/addon-notes": "5.1.11", "@storybook/addon-options": "5.1.11", "@storybook/react": "5.1.11", "@testing-library/react-hooks": "2.0.3", - "@types/jest": "24.0.18", + "@types/jest": "24.0.19", "@types/react": "16.9.2", "babel-core": "6.26.3", "babel-loader": "8.0.6", "babel-plugin-dynamic-import-node": "2.3.0", - "fork-ts-checker-webpack-plugin": "1.5.0", + "fork-ts-checker-webpack-plugin": "1.5.1", "gh-pages": "2.1.1", "husky": "3.0.9", "jest": "24.9.0", diff --git a/src/__stories__/useMultiStateValidator.story.tsx b/src/__stories__/useMultiStateValidator.story.tsx new file mode 100644 index 00000000..91eb6194 --- /dev/null +++ b/src/__stories__/useMultiStateValidator.story.tsx @@ -0,0 +1,51 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useMultiStateValidator } from '../'; +import ShowDocs from './util/ShowDocs'; + +const DemoStateValidator = (s: number[]) => [s.every((num: number) => !(num % 2))] as [boolean]; +const Demo = () => { + const [state1, setState1] = React.useState(1); + const [state2, setState2] = React.useState(1); + const [state3, setState3] = React.useState(1); + const [[isValid]] = useMultiStateValidator([state1, state2, state3], DemoStateValidator); + + return ( +
+
Below fields will be valid if all of them is even
+
+ ) => { + setState1((ev.target.value as unknown) as number); + }} + /> + ) => { + setState2((ev.target.value as unknown) as number); + }} + /> + ) => { + setState3((ev.target.value as unknown) as number); + }} + /> + {isValid !== null && {isValid ? 'Valid!' : 'Invalid'}} +
+ ); +}; + +storiesOf('State|useMultiStateValidator', module) + .add('Docs', () => ) + .add('Demo', () => ); 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
{JSON.stringify(state, null, 2)}
; +}; + +storiesOf('State|useRafState', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/src/__tests__/useMultiStateValidator.ts b/src/__tests__/useMultiStateValidator.ts new file mode 100644 index 00000000..cf191ca6 --- /dev/null +++ b/src/__tests__/useMultiStateValidator.ts @@ -0,0 +1,125 @@ +import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { useState } from 'react'; +import { MultiStateValidator, useMultiStateValidator } from '../useMultiStateValidator'; +import { UseValidatorReturn, ValidityState } from '../useStateValidator'; + +interface Mock extends jest.Mock {} + +describe('useMultiStateValidator', () => { + it('should be defined', () => { + expect(useMultiStateValidator).toBeDefined(); + }); + + const defaultStatesValidator = (states: number[]) => [states.every(num => !(num % 2))]; + + function getHook( + fn: MultiStateValidator = jest.fn(defaultStatesValidator), + initialStates = [1, 2], + initialValidity = [false] + ): [MultiStateValidator, RenderHookResult]>] { + return [ + fn, + renderHook( + ({ initStates, validator, initValidity }) => { + const [states, setStates] = useState(initStates); + + return [setStates, useMultiStateValidator(states, validator, initValidity)]; + }, + { + initialProps: { + initStates: initialStates, + initValidity: initialValidity, + validator: fn, + }, + } + ), + ]; + } + + it('should return an array of two elements', () => { + const [, hook] = getHook(); + const res = hook.result.current[1]; + + expect(Array.isArray(res)).toBe(true); + expect(res.length).toBe(2); + }); + + it('should call validator on init', () => { + const [spy] = getHook(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should call validator on any of states changed', () => { + const [spy, hook] = getHook(); + + expect(spy).toHaveBeenCalledTimes(1); + act(() => hook.result.current[0]([4, 2])); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it("should NOT call validator on it's change", () => { + const [spy, hook] = getHook(); + const newValidator: MultiStateValidator = jest.fn(states => [states!.every(num => !!(num % 2))]); + + expect(spy).toHaveBeenCalledTimes(1); + hook.rerender({ validator: newValidator }); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should throw if states is not an object', () => { + expect(() => { + // @ts-ignore + const [, hook] = getHook(defaultStatesValidator, 123); + + if (hook.result.error) { + throw hook.result.error; + } + }).toThrowError('states expected to be an object or array, got number'); + }); + + it('first returned element should represent current validity state', () => { + const [, hook] = getHook(); + let [setState, [validity]] = hook.result.current; + expect(validity).toEqual([false]); + + act(() => setState([4, 2])); + [setState, [validity]] = hook.result.current; + expect(validity).toEqual([true]); + + act(() => setState([4, 5])); + [setState, [validity]] = hook.result.current; + expect(validity).toEqual([false]); + }); + + it('second returned element should re-call validation', () => { + const [spy, hook] = getHook(); + const [, [, revalidate]] = hook.result.current; + + expect(spy).toHaveBeenCalledTimes(1); + act(() => revalidate()); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('validator should receive states as a firs argument', () => { + const [spy, hook] = getHook(); + const [setState] = hook.result.current; + + expect((spy as Mock).mock.calls[0].length).toBe(1); + expect((spy as Mock).mock.calls[0][0]).toEqual([1, 2]); + act(() => setState([4, 6])); + expect((spy as Mock).mock.calls[1][0]).toEqual([4, 6]); + }); + + it('if validator expects 2nd parameters it should pass a validity setter there', () => { + const spy = (jest.fn((states: number[], done) => { + done([states.every(num => !!(num % 2))]); + }) as unknown) as MultiStateValidator; + const [, hook] = getHook(spy, [1, 3]); + const [, [validity]] = hook.result.current; + + expect((spy as Mock).mock.calls[0].length).toBe(2); + expect((spy as Mock).mock.calls[0][0]).toEqual([1, 3]); + expect(validity).toEqual([true]); + }); +}); 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); + }); +}); diff --git a/src/index.ts b/src/index.ts index e1e9b337..9c660296 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,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 */ @@ -89,6 +91,7 @@ export { default as useUpdateEffect } from './useUpdateEffect'; export { default as useUpsert } from './useUpsert'; export { default as useVideo } from './useVideo'; export { default as useStateValidator } from './useStateValidator'; +export { useMultiStateValidator } from './useMultiStateValidator'; export { useWait, Waiter } from './useWait'; export { default as useWindowScroll } from './useWindowScroll'; export { default as useWindowSize } from './useWindowSize'; 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): State => { } } - const frame = useRef(0); - const [state, setState] = useState({ + const [state, setState] = useRafState({ docX: 0, docY: 0, posX: 0, @@ -32,34 +33,29 @@ const useMouse = (ref: RefObject): State => { useEffect(() => { const moveHandler = (event: MouseEvent) => { - cancelAnimationFrame(frame.current); + 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; - 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; - - 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/useMultiStateValidator.ts b/src/useMultiStateValidator.ts new file mode 100644 index 00000000..29cb12b3 --- /dev/null +++ b/src/useMultiStateValidator.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { DispatchValidity, UseValidatorReturn, ValidityState } from './useStateValidator'; + +export type MultiStateValidatorStates = any[] | { [p: string]: any } | { [p: number]: any }; + +export interface MultiStateValidator< + V extends ValidityState = ValidityState, + S extends MultiStateValidatorStates = MultiStateValidatorStates +> { + (states: S): V; + + (states: S, done: DispatchValidity): void; +} + +export function useMultiStateValidator< + V extends ValidityState = ValidityState, + S extends MultiStateValidatorStates = MultiStateValidatorStates +>(states: S, validator: MultiStateValidator, initialValidity: V = [undefined] as V): UseValidatorReturn { + if (typeof states !== 'object') { + throw new Error('states expected to be an object or array, got ' + typeof states); + } + + const validatorFn = useRef(validator); + + const [validity, setValidity] = useState(initialValidity); + + const deps = Array.isArray(states) ? states : Object.values(states); + const validate = useCallback(() => { + if (validatorFn.current.length === 2) { + validatorFn.current(states, setValidity); + } else { + setValidity(validatorFn.current(states)); + } + }, deps); + + useEffect(() => { + validate(); + }, deps); + + return [validity, validate]; +} 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 = (initialState: S | (() => S)): [S, Dispatch>] => { + 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): State => { } } - const frame = useRef(0); - const [state, setState] = useState({ + const [state, setState] = useRafState({ 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): 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({ + const [state, setState] = useRafState({ 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 { diff --git a/tsconfig.json b/tsconfig.json index b24d6137..135d602b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,8 @@ "noImplicitAny": false, "noFallthroughCasesInSwitch": true, "outDir": "lib", - "lib": ["es2018", "dom"] + "lib": ["es2018", "dom"], + "importHelpers": true }, "exclude": [ "node_modules", diff --git a/yarn.lock b/yarn.lock index f7a974f2..558545f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1712,10 +1712,10 @@ into-stream "^4.0.0" lodash "^4.17.4" -"@shopify/async@^2.0.7": - version "2.0.7" - resolved "https://registry.yarnpkg.com/@shopify/async/-/async-2.0.7.tgz#944992bc1721df6c363b3f0f31be1dad0e75e929" - integrity sha512-wYGjqPhpna4ShYbUmlD2fPv5ZkjNlCZtU7huUU8/snnyPmdgL/Rn5M5FPP6Apr7/hU5RgqMj2tJFs37ORz/VaQ== +"@shopify/async@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@shopify/async/-/async-2.0.8.tgz#6ecded5fe8c5f6f20a74f212e4cd8f307e9e5138" + integrity sha512-jiqVW8SA79eO3dWwJCCcLTch6TJK30i796WKyF1bGjIhwAaD6qRlspj5Ffzfg3r4RQbGMJheOVMdWhzctwxgKg== "@shopify/decorators@^1.1.5": version "1.1.5" @@ -1729,16 +1729,17 @@ resolved "https://registry.yarnpkg.com/@shopify/function-enhancers/-/function-enhancers-1.0.5.tgz#7c3e516e26ce7a9b63c263679bdcf5121d994a10" integrity sha512-34ML8DX4RmmA9hXDlf2BAz4SA37unShZxoBRPz585a+FaEzNcMvw5NzLD+Ih9XrP/wrxTUcN+p6pazvoS+jB7w== -"@shopify/jest-dom-mocks@2.8.2": - version "2.8.2" - resolved "https://registry.yarnpkg.com/@shopify/jest-dom-mocks/-/jest-dom-mocks-2.8.2.tgz#477c3159897807cc8d7797c33e8a79e787051779" - integrity sha512-4drt+S1cQ1ZSP1DaEHAj5XPPCiI2R8IIt+ZnH9h08Ngy8PjtjFFNHNcSJ6bKBmk7eO2c6+5UaJQzNcg56nt7gg== +"@shopify/jest-dom-mocks@2.8.3": + version "2.8.3" + resolved "https://registry.yarnpkg.com/@shopify/jest-dom-mocks/-/jest-dom-mocks-2.8.3.tgz#3c00872c1b996290dc2a8222d2701c131150b305" + integrity sha512-K544qSPkjlf/ze0urmgbN50ti4wcF+Vy1IQP8tbnkqA362slMXjdLokuB7oSIkntYLL9TmlEkWCxSRC17LXtnQ== dependencies: - "@shopify/async" "^2.0.7" + "@shopify/async" "^2.0.8" "@shopify/decorators" "^1.1.5" "@types/fetch-mock" "^6.0.1" "@types/lolex" "^2.1.3" fetch-mock "^6.3.0" + lodash ">=4.0.0 <5.0.0" lolex "^2.7.5" promise "^8.0.3" tslib "^1.9.3" @@ -2289,10 +2290,10 @@ resolved "https://registry.yarnpkg.com/@types/jest-diff/-/jest-diff-20.0.1.tgz#35cc15b9c4f30a18ef21852e255fdb02f6d59b89" integrity sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA== -"@types/jest@24.0.18": - version "24.0.18" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.0.18.tgz#9c7858d450c59e2164a8a9df0905fc5091944498" - integrity sha512-jcDDXdjTcrQzdN06+TSVsPPqxvsZA/5QkYfIZlq1JMw7FdP5AZylbOc+6B/cuDurctRe+MziUMtQ3xQdrbjqyQ== +"@types/jest@24.0.19": + version "24.0.19" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.0.19.tgz#f7036058d2a5844fe922609187c0ad8be430aff5" + integrity sha512-YYiqfSjocv7lk5H/T+v5MjATYjaTMsUkbDnjGqSMoO88jWdtJXJV4ST/7DKZcoMHMBvB2SeSfyOzZfkxXHR5xg== dependencies: "@types/jest-diff" "*" @@ -5910,10 +5911,10 @@ fork-ts-checker-webpack-plugin@1.1.1: tapable "^1.0.0" worker-rpc "^0.1.0" -fork-ts-checker-webpack-plugin@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-1.5.0.tgz#ce1d77190b44d81a761b10b6284a373795e41f0c" - integrity sha512-zEhg7Hz+KhZlBhILYpXy+Beu96gwvkROWJiTXOCyOOMMrdBIRPvsBpBqgTI4jfJGrJXcqGwJR8zsBGDmzY0jsA== +fork-ts-checker-webpack-plugin@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-1.5.1.tgz#f82d078ba8911c7b2c70703ffb3cbe588b33fbaa" + integrity sha512-IbVh1Z46dmCXJMg6We8s9jYwCAzzSv2Tgj+G2Sg/8pFantHDBrAg/rQyPnmAWLS/djW7n4VEltoEglbtTvt0wQ== dependencies: babel-code-frame "^6.22.0" chalk "^2.4.1" @@ -8336,7 +8337,7 @@ lodash.without@~4.4.0: resolved "https://registry.yarnpkg.com/lodash.without/-/lodash.without-4.4.0.tgz#3cd4574a00b67bae373a94b748772640507b7aac" integrity sha1-PNRXSgC2e643OpS3SHcmQFB7eqw= -lodash@>4.17.4, lodash@^4.0.1, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.2.1: +lodash@>4.17.4, "lodash@>=4.0.0 <5.0.0", lodash@^4.0.1, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.2.1: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== @@ -12612,7 +12613,7 @@ tslib@1.9.0, tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.0.tgz#e37a86fda8cbbaf23a057f473c9f4dc64e5fc2e8" integrity sha512-f/qGG2tUkrISBlQZEjEqoZ3B2+npJjIf04H1wuAv9iA8i04Icp+61KRXxFdha22670NJopsZCIjhC3SnjPRKrQ== -tslib@^1.9.3: +tslib@^1.10.0, tslib@^1.9.3: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==