├── .all-contributorsrc ├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── __tests__ ├── .eslintrc └── index.test.js ├── package-lock.json ├── package.json ├── src └── index.js └── types ├── index.d.ts ├── tsconfig.json └── use-event-listener-tests.tsx /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "use-event-listener", 3 | "projectOwner": "donavon", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "contributors": [ 12 | { 13 | "login": "donavon", 14 | "name": "Donavon West", 15 | "avatar_url": "https://avatars3.githubusercontent.com/u/887639?v=4", 16 | "profile": "http://donavon.com", 17 | "contributions": [ 18 | "infra", 19 | "test", 20 | "example", 21 | "ideas", 22 | "maintenance", 23 | "review", 24 | "tool", 25 | "code" 26 | ] 27 | }, 28 | { 29 | "login": "third774", 30 | "name": "Kevin Kipp", 31 | "avatar_url": "https://avatars3.githubusercontent.com/u/8732191?v=4", 32 | "profile": "https://github.com/third774", 33 | "contributions": [ 34 | "code" 35 | ] 36 | }, 37 | { 38 | "login": "wKovacs64", 39 | "name": "Justin Hall", 40 | "avatar_url": "https://avatars1.githubusercontent.com/u/1288694?v=4", 41 | "profile": "https://github.com/wKovacs64", 42 | "contributions": [ 43 | "code", 44 | "doc" 45 | ] 46 | }, 47 | { 48 | "login": "huan086", 49 | "name": "Jeow Li Huan", 50 | "avatar_url": "https://avatars2.githubusercontent.com/u/1448788?v=4", 51 | "profile": "https://github.com/huan086", 52 | "contributions": [ 53 | "review" 54 | ] 55 | }, 56 | { 57 | "login": "normanrz", 58 | "name": "Norman Rzepka", 59 | "avatar_url": "https://avatars1.githubusercontent.com/u/335438?v=4", 60 | "profile": "http://normanrz.com/", 61 | "contributions": [ 62 | "ideas" 63 | ] 64 | }, 65 | { 66 | "login": "bvanderdrift", 67 | "name": "Beer van der Drift", 68 | "avatar_url": "https://avatars1.githubusercontent.com/u/6398452?v=4", 69 | "profile": "https://github.com/bvanderdrift", 70 | "contributions": [ 71 | "test", 72 | "code" 73 | ] 74 | }, 75 | { 76 | "login": "pruge", 77 | "name": "clingsoft", 78 | "avatar_url": "https://avatars1.githubusercontent.com/u/5827473?v=4", 79 | "profile": "https://github.com/pruge", 80 | "contributions": [ 81 | "code" 82 | ] 83 | } 84 | ], 85 | "contributorsPerLine": 7, 86 | "skipCi": true 87 | } 88 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_size = 2 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "amex", 3 | "rules": { 4 | "consistent-return": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | .DS_Store 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | # next.js build output 62 | .next 63 | 64 | # lib 65 | lib/ 66 | dist/ 67 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | .editorconfig 4 | .eslintrc 5 | .babelrc 6 | __tests__ 7 | coverage 8 | .prettierrc 9 | .travis.yml 10 | .all-contributorsrc 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "14" 5 | 6 | jobs: 7 | include: 8 | - stage: test 9 | script: npm t 10 | - stage: npm release 11 | if: branch = master 12 | deploy: 13 | provider: npm 14 | email: $NPM_EMAIL 15 | api_key: $NPM_TOKEN 16 | skip_cleanup: true 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present Donavon West 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @use-it/event-listener 2 | 3 | A custom React Hook that provides a declarative useEventListener. 4 | 5 | [![npm version](https://badge.fury.io/js/%40use-it%2Fevent-listener.svg)](https://badge.fury.io/js/%40use-it%2Fevent-listener) [![All Contributors](https://img.shields.io/badge/all_contributors-3-orange.svg?style=flat-square)](#contributors) 6 | 7 | This hook was inspired by [Dan Abramov](https://github.com/gaearon)'s 8 | blog post 9 | ["Making setInterval Declarative with React Hooks"](https://overreacted.io/making-setinterval-declarative-with-react-hooks/). 10 | 11 | I needed a way to simplify the plumbing around adding and removing an event listener 12 | in a custom hook. 13 | That lead to a [chain of tweets](https://twitter.com/donavon/status/1093612936621379584) 14 | between Dan and myself. 15 | 16 | ## Installation 17 | 18 | ```bash 19 | $ npm i @use-it/event-listener 20 | ``` 21 | 22 | or 23 | 24 | ```bash 25 | $ yarn add @use-it/event-listener 26 | ``` 27 | 28 | ## Usage 29 | 30 | Here is a basic setup. 31 | 32 | ```js 33 | useEventListener(eventName, handler, element, options); 34 | ``` 35 | 36 | ### Parameters 37 | 38 | Here are the parameters that you can use. (\* = optional) 39 | 40 | | Parameter | Description | 41 | | :---------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 42 | | `eventName` | The event name (string). Here is a list of [common events](https://developer.mozilla.org/en-US/docs/Web/Events). | 43 | | `handler` | A function that will be called whenever `eventName` fires on `element`. | 44 | | `element`\* | An optional element to listen on. Defaults to `global` (i.e., `window`). | 45 | | `options`\* | An object `{ capture?: boolean, passive?: boolean, once?: boolean }` to be passed to `addEventListener`. For advanced use cases. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener) for details. | 46 | 47 | ### Return 48 | 49 | This hook returns nothing. 50 | 51 | ## Example 52 | 53 | Let's look at some sample code. Suppose you would like to track the mouse 54 | position. You _could_ subscribe to mouse move events with like this. 55 | 56 | ```js 57 | const useMouseMove = () => { 58 | const [coords, setCoords] = useState([0, 0]); 59 | 60 | useEffect(() => { 61 | const handler = ({ clientX, clientY }) => { 62 | setCoords([clientX, clientY]); 63 | }; 64 | window.addEventListener('mousemove', handler); 65 | return () => { 66 | window.removeEventListener('mousemove', handler); 67 | }; 68 | }, []); 69 | 70 | return coords; 71 | }; 72 | ``` 73 | 74 | Here we're using `useEffect` to roll our own handler add/remove event listener. 75 | 76 | `useEventListener` abstracts this away. You only need to care about the event name 77 | and the handler function. 78 | 79 | ```js 80 | const useMouseMove = () => { 81 | const [coords, setCoords] = useState([0, 0]); 82 | 83 | useEventListener('mousemove', ({ clientX, clientY }) => { 84 | setCoords([clientX, clientY]); 85 | }); 86 | 87 | return coords; 88 | }; 89 | ``` 90 | 91 | ## Live demo 92 | 93 | You can view/edit the sample code above on CodeSandbox. 94 | 95 | [![Edit demo app on CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/k38lyx2q9o) 96 | 97 | ## License 98 | 99 | **[MIT](LICENSE)** Licensed 100 | 101 | ## Contributors 102 | 103 | Thanks goes to these wonderful people ([emoji key](https://github.com/all-contributors/all-contributors#emoji-key)): 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 |

Donavon West

🚇 ⚠️ 💡 🤔 🚧 👀 🔧 💻

Kevin Kipp

💻

Justin Hall

💻 📖

Jeow Li Huan

👀

Norman Rzepka

🤔

Beer van der Drift

⚠️ 💻

clingsoft

💻
119 | 120 | 121 | 122 | 123 | 124 | 125 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 126 | -------------------------------------------------------------------------------- /__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "amex/test", 3 | "rules": { 4 | "react/prop-types": 0, 5 | "react/button-has-type": 0, 6 | "react/jsx-filename-extension": 0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/no-disabled-tests */ 2 | import { renderHook } from '@testing-library/react-hooks'; 3 | import '@testing-library/jest-dom/extend-expect'; 4 | 5 | import useEventListener from '../src'; 6 | 7 | const mouseMoveEvent = { clientX: 100, clientY: 200 }; 8 | let hackHandler = null; 9 | 10 | const mockElement = { 11 | addEventListener: (eventName, handler, options) => { 12 | hackHandler = handler; 13 | }, 14 | removeEventListener: () => { 15 | hackHandler = null; 16 | }, 17 | dispatchEvent: (event) => { 18 | hackHandler(event); 19 | }, 20 | }; 21 | 22 | describe('useEventListener', () => { 23 | test('import useEventListener from "@use-it/event-listener"', () => { 24 | expect(typeof useEventListener).toBe('function'); 25 | }); 26 | 27 | test('you pass an `eventName`, `handler`, and an `element`', async () => { 28 | const handler = jest.fn(); 29 | const addEventListenerSpy = jest.spyOn(mockElement, 'addEventListener'); 30 | 31 | const { waitForNextUpdate } = renderHook(() => 32 | useEventListener('foo', handler, mockElement) 33 | ); 34 | 35 | await waitForNextUpdate; 36 | expect(addEventListenerSpy).toBeCalled(); 37 | 38 | mockElement.dispatchEvent(mouseMoveEvent); 39 | expect(handler).toBeCalledWith(mouseMoveEvent); 40 | 41 | addEventListenerSpy.mockRestore(); 42 | }); 43 | 44 | test.skip('`element` is optional (defaults to `window`/`global`)', () => { 45 | const handler = jest.fn(); 46 | const addEventListenerSpy = jest.spyOn(global, 'addEventListener'); 47 | 48 | renderHook(() => useEventListener('foo', handler)); 49 | 50 | expect(addEventListenerSpy).toBeCalled(); 51 | 52 | addEventListenerSpy.mockRestore(); 53 | }); 54 | 55 | test.skip('does not add event listener to `window` if `element` is `null`', () => { 56 | const handler = jest.fn(); 57 | const addEventListenerSpy = jest.spyOn(global, 'addEventListener'); 58 | 59 | renderHook(() => useEventListener('foo', handler, null)); 60 | 61 | expect(addEventListenerSpy).not.toBeCalledWith('foo', handler); 62 | }); 63 | 64 | test.skip('fails safe with SSR (i.e. no window)', () => { 65 | const handler = jest.fn(); 66 | 67 | renderHook(() => useEventListener('foo', handler, {})); 68 | }); 69 | 70 | test('`options` are passed to `addEventListener`', () => { 71 | const handler = jest.fn(); 72 | const addEventListenerSpy = jest.spyOn(mockElement, 'addEventListener'); 73 | 74 | renderHook(() => { 75 | useEventListener('foo', handler, mockElement, { 76 | capture: true, 77 | passive: true, 78 | }); 79 | }); 80 | 81 | expect(addEventListenerSpy).toBeCalledWith( 82 | 'foo', 83 | expect.any(Function), 84 | expect.objectContaining({ capture: true, passive: true }) 85 | ); 86 | 87 | addEventListenerSpy.mockRestore(); 88 | }); 89 | 90 | test('changing the identity of `options` does not cause effect to rerun', () => { 91 | const handler = jest.fn(); 92 | const addEventListenerSpy = jest.spyOn(mockElement, 'addEventListener'); 93 | 94 | const { rerender } = renderHook(() => { 95 | useEventListener('foo', handler, mockElement, { 96 | capture: true, 97 | passive: true, 98 | }); 99 | }); 100 | const numberOfCalls = addEventListenerSpy.mock.calls.length; 101 | 102 | rerender(() => { 103 | useEventListener('foo', handler, mockElement, { 104 | capture: true, 105 | passive: true, 106 | }); 107 | }); 108 | expect(addEventListenerSpy).toBeCalledTimes(numberOfCalls); 109 | 110 | addEventListenerSpy.mockRestore(); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@use-it/event-listener", 3 | "publishConfig": { 4 | "access": "public" 5 | }, 6 | "version": "0.1.7", 7 | "description": "A custom React Hook that provides a useEventListener.", 8 | "main": "dist/event-listener.js", 9 | "umd:main": "dist/event-listener.umd.js", 10 | "module": "dist/event-listener.m.js", 11 | "source": "src/index.js", 12 | "types": "types/index.d.ts", 13 | "license": "MIT", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/donavon/use-event-listener.git" 17 | }, 18 | "scripts": { 19 | "prepublishOnly": "npm run build", 20 | "lint": "eslint src", 21 | "test": "jest --verbose --coverage --silent && tsc -p types/tsconfig.json", 22 | "test:watch": "jest --watch --runInBand --silent", 23 | "tsc": "tsc -p types/tsconfig.json", 24 | "prebuild": "npm run lint && npm t && rimraf dist", 25 | "build": "microbundle -o dist/ --sourcemap false --target web", 26 | "dev": "microbundle watch -o dist/ --sourcemap false --compress false" 27 | }, 28 | "keywords": [ 29 | "react-hooks", 30 | "hooks", 31 | "react", 32 | "utils", 33 | "lib", 34 | "event-listener" 35 | ], 36 | "author": "donavon", 37 | "devDependencies": { 38 | "@babel/core": "^7.12.3", 39 | "@babel/preset-env": "^7.12.1", 40 | "@babel/preset-react": "^7.12.5", 41 | "@testing-library/jest-dom": "^5.11.6", 42 | "@testing-library/react-hooks": "^3.4.2", 43 | "babel-core": "^7.0.0-bridge.0", 44 | "babel-jest": "^26.6.3", 45 | "eslint": "^7.13.0", 46 | "eslint-config-amex": "^13.1.0", 47 | "jest": "^27.3.1", 48 | "microbundle": "^0.12.4", 49 | "react": "^17.0.2", 50 | "react-dom": "^17.0.2", 51 | "react-test-renderer": "^16.14.0", 52 | "rimraf": "^3.0.2", 53 | "typescript": "^4.4.4" 54 | }, 55 | "peerDependencies": { 56 | "react": ">=16.8.0" 57 | }, 58 | "jest": { 59 | "coverageThreshold": { 60 | "global": { 61 | "statements": 90, 62 | "branches": 60, 63 | "functions": 100, 64 | "lines": 90 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-params */ 2 | import { useRef, useEffect } from 'react'; 3 | 4 | const useEventListener = ( 5 | eventName, 6 | handler, 7 | element = global, 8 | options = {} 9 | ) => { 10 | const savedHandler = useRef(); 11 | const { capture, passive, once } = options; 12 | 13 | useEffect(() => { 14 | savedHandler.current = handler; 15 | }, [handler]); 16 | 17 | useEffect(() => { 18 | const isSupported = element && element.addEventListener; 19 | if (!isSupported) { 20 | return; 21 | } 22 | 23 | const eventListener = (event) => savedHandler.current(event); 24 | const opts = { capture, passive, once }; 25 | element.addEventListener(eventName, eventListener, opts); 26 | return () => { 27 | element.removeEventListener(eventName, eventListener, opts); 28 | }; 29 | }, [eventName, element, capture, passive, once]); 30 | }; 31 | 32 | export default useEventListener; 33 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | type Options = Pick; 2 | 3 | /** 4 | * A custom React Hook that provides a declarative useEventListener. 5 | */ 6 | declare function useEventListener( 7 | eventName: K, 8 | handler: HTMLElementEventMap[K], 9 | // allow null to support usage with `useRef(null)` 10 | element: HTMLElement | null, 11 | options?: Options 12 | ): void; 13 | declare function useEventListener( 14 | eventName: K, 15 | handler: DocumentEventMap[K], 16 | element: Document, 17 | options?: Options 18 | ): void; 19 | declare function useEventListener( 20 | eventName: K, 21 | handler: WindowEventMap[K], 22 | element?: Window, 23 | options?: Options 24 | ): void; 25 | declare function useEventListener( 26 | eventName: string, 27 | handler: EventListenerOrEventListenerObject, 28 | element?: HTMLElement | Window | Document | null, 29 | options?: Options 30 | ): void; 31 | declare function useEventListener< 32 | K extends keyof (HTMLElementEventMap & DocumentEventMap & WindowEventMap) 33 | >( 34 | eventName: K, 35 | handler: ( 36 | event: (HTMLElementEventMap & DocumentEventMap & WindowEventMap)[K] 37 | ) => void, 38 | element?: HTMLElement | Document | Window | null, 39 | options?: Options 40 | ): void; 41 | 42 | export as namespace useEventListener; 43 | export default useEventListener; 44 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "moduleResolution": "node", 5 | "lib": [ "es2015", "dom" ], 6 | "noEmit": true, 7 | "strict": true, 8 | "baseUrl": ".", 9 | "jsx": "preserve", 10 | "forceConsistentCasingInFileNames": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /types/use-event-listener-tests.tsx: -------------------------------------------------------------------------------- 1 | import useEventListener from './index' 2 | 3 | declare const element: HTMLElement 4 | declare const elementOrNull: HTMLElement | null 5 | useEventListener('click', (_: MouseEvent) => { }, element) 6 | useEventListener('click', (_: MouseEvent) => { }, elementOrNull) 7 | useEventListener('fullscreenchange', (_: Event) => { }, elementOrNull) 8 | 9 | useEventListener('click', (_: MouseEvent) => { }, document) 10 | useEventListener('resize', (_: UIEvent) => { }, document) 11 | 12 | useEventListener('popstate', (_: PopStateEvent) => { }, window) 13 | useEventListener('popstate', (_: PopStateEvent) => { }) 14 | 15 | declare const something: HTMLElement | Window | Document 16 | useEventListener('click', (_: MouseEvent) => { }, something) 17 | useEventListener('click', (_: Event) => { }, something) 18 | 19 | declare const eventListenerObject: EventListenerObject 20 | useEventListener('click', eventListenerObject) 21 | useEventListener('click', eventListenerObject, something) 22 | 23 | 24 | useEventListener('click', (_: MouseEvent) => { }, window, undefined) 25 | useEventListener('click', (_: MouseEvent) => { }, window, { passive: true }) 26 | useEventListener('click', (_: MouseEvent) => { }, window, { capture: true, passive: true }) 27 | --------------------------------------------------------------------------------