diff --git a/README.md b/README.md index 6f0ededb2..c1d46404b 100644 --- a/README.md +++ b/README.md @@ -145,3 +145,5 @@ import { useMountEffect } from "@react-hookz/web/esnext"; - [**`useDocumentTitle`**](https://react-hookz.github.io/web/?path=/docs/dom-usedocumenttitle) — Sets title of the page. + - [**`useEventListener`**](https://react-hookz.github.io/web/?path=/docs/dom-useeventlistener) + — Subscribes an event listener to the target, and automatically unsubscribes it on unmount. diff --git a/src/index.ts b/src/index.ts index ccf1c824b..867ffd59d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,3 +76,4 @@ export { useMediaQuery } from './useMediaQuery/useMediaQuery'; // Dom export { useDocumentTitle, IUseDocumentTitleOptions } from './useDocumentTitle/useDocumentTitle'; +export { useEventListener } from './useEventListener/useEventListener'; diff --git a/src/useEventListener/__docs__/example.stories.tsx b/src/useEventListener/__docs__/example.stories.tsx new file mode 100644 index 000000000..aaf95f098 --- /dev/null +++ b/src/useEventListener/__docs__/example.stories.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { useEventListener, useToggle } from '../..'; + +export const Example: React.FC = () => { + const [state, setState] = useState(); + const [mounted, toggleMounted] = useToggle(true); + + const ToggledComponent = () => { + useEventListener( + window, + 'mousemove', + () => { + setState(new Date()); + }, + { passive: true } + ); + + return
child component is mounted
; + }; + + return ( +
+
+ Below state is update on window's `mousemove` event. +
+ You can unmount child component to ensure that event is unsubscribed when component + unmounted. +
+ +
+
{state ? `mouse last moved: ${state}` : 'mouse not moved yet'}
+ +
+
+ {mounted && } + +
+
+ ); +}; diff --git a/src/useEventListener/__docs__/story.mdx b/src/useEventListener/__docs__/story.mdx new file mode 100644 index 000000000..7ed7725a9 --- /dev/null +++ b/src/useEventListener/__docs__/story.mdx @@ -0,0 +1,36 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './example.stories'; + + + +# useEventListener + +Subscribes an event listener to the target, and automatically unsubscribes it on unmount. + +- Automatically unsubscribes on component unmount. +- Properly handle event listener objects. +- SSR-friendly. +- Full TypeScript support for any target type. + +#### Example + + + + + +## Reference + +```ts +export function useEventListener( + target: ITargetOrTargetRef, + ...params: + | Parameters + | [string, EventListenerOrEventListenerObject, ...any] +): void; +``` + +#### Arguments + +- **target** _`ITargetOrTargetRef`_ - Element ref object or element itself. +- **params** - Parameters specific for target's `addEventListener`. Commonly, it is + `[eventName, listener, options]`. diff --git a/src/useEventListener/__tests__/dom.ts b/src/useEventListener/__tests__/dom.ts new file mode 100644 index 000000000..4527d7251 --- /dev/null +++ b/src/useEventListener/__tests__/dom.ts @@ -0,0 +1,89 @@ +/* eslint-disable func-names */ +import { renderHook } from '@testing-library/react-hooks/dom'; +import { useEventListener } from '../..'; + +describe('useEventListener', () => { + it('should be defined', () => { + expect(useEventListener).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useEventListener(null, '', () => {})); + expect(result.error).toBeUndefined(); + }); + + it('should bind listener on mount and unbind on unmount', () => { + const div = document.createElement('div'); + const addSpy = jest.spyOn(div, 'addEventListener'); + const removeSpy = jest.spyOn(div, 'removeEventListener'); + + const { rerender, unmount } = renderHook(() => + useEventListener(div, 'resize', () => {}, { passive: true }) + ); + + expect(addSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledTimes(0); + + rerender(); + expect(addSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledTimes(0); + + unmount(); + expect(addSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledTimes(1); + }); + + it('should work with react refs', () => { + const div = document.createElement('div'); + const addSpy = jest.spyOn(div, 'addEventListener'); + const removeSpy = jest.spyOn(div, 'removeEventListener'); + + const { rerender, unmount } = renderHook(() => + useEventListener({ current: div }, 'resize', () => {}, { passive: true }) + ); + + expect(addSpy).toHaveBeenCalledTimes(1); + expect(addSpy.mock.calls[0][2]).toStrictEqual({ passive: true }); + expect(removeSpy).toHaveBeenCalledTimes(0); + + rerender(); + expect(addSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledTimes(0); + + unmount(); + expect(addSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledTimes(1); + }); + + it('should invoke provided function on event trigger with proper context', () => { + const div = document.createElement('div'); + let context: any; + const spy = jest.fn(function (this: any) { + context = this; + }); + + renderHook(() => useEventListener(div, 'resize', spy, { passive: true })); + + const evt = new Event('resize'); + div.dispatchEvent(evt); + + expect(spy).toHaveBeenCalledWith(evt); + expect(context).toBe(div); + }); + + it('should properly handle event listener objects', () => { + const div = document.createElement('div'); + let context: any; + const spy = jest.fn(function (this: any) { + context = this; + }); + + renderHook(() => useEventListener(div, 'resize', { handleEvent: spy }, { passive: true })); + + const evt = new Event('resize'); + div.dispatchEvent(evt); + + expect(spy).toHaveBeenCalledWith(evt); + expect(context).toBe(div); + }); +}); diff --git a/src/useEventListener/__tests__/ssr.ts b/src/useEventListener/__tests__/ssr.ts new file mode 100644 index 000000000..e1a87c897 --- /dev/null +++ b/src/useEventListener/__tests__/ssr.ts @@ -0,0 +1,13 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useEventListener } from '../..'; + +describe('useEventListener', () => { + it('should be defined', () => { + expect(useEventListener).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useEventListener(null, 'random name', () => {})); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/src/useEventListener/useEventListener.ts b/src/useEventListener/useEventListener.ts new file mode 100644 index 000000000..09ce6c97a --- /dev/null +++ b/src/useEventListener/useEventListener.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { MutableRefObject, RefObject, useEffect, useMemo } from 'react'; +import { hasOwnProperty } from '../util/misc'; +import { useSyncedRef, useIsMounted } from '..'; + +type ITargetOrTargetRef = T | null | RefObject | MutableRefObject; + +/** + * Subscribes an event listener to the target, and automatically unsubscribes + * it on unmount. + * + * @param target Element ref object or element itself. + * @param params Parameters specific for target's `addEventListener`. Commonly, + * it is `[eventName, listener, options]`. + */ +export function useEventListener( + target: ITargetOrTargetRef, + ...params: + | Parameters + | [string, EventListenerOrEventListenerObject | ((...args: any[]) => any), ...any] +): void { + // extract current target from ref object + const tgt: T = + target && hasOwnProperty(target, 'current') + ? (target as MutableRefObject).current + : (target as T); + const isMounted = useIsMounted(); + + // create static event listener + const listenerRef = useSyncedRef(params[1]); + const eventListener = useMemo( + () => + // as some event listeners designed to be used through `this` + // it is better to make listener a conventional function as it + // infers call context + // eslint-disable-next-line func-names + function (this: T, ...args) { + // normally, such situation should not happen, but better to + // have back covered + /* istanbul ignore next */ + if (!isMounted()) return; + + // we dont care if non-listener provided, simply dont do anything + /* istanbul ignore else */ + if (typeof listenerRef.current === 'function') { + listenerRef.current.apply(this, args); + } else if (typeof (listenerRef.current as EventListenerObject).handleEvent === 'function') { + (listenerRef.current as EventListenerObject).handleEvent.apply(this, args); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + if (!tgt) return undefined; + + const restParams = params.slice(2); + + tgt.addEventListener(params[0], eventListener, ...restParams); + + return () => tgt.removeEventListener(params[0], eventListener, ...restParams); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tgt, params[0]]); +} diff --git a/src/useThrottledEffect/__docs__/story.mdx b/src/useThrottledEffect/__docs__/story.mdx index 7ae210915..9437fd2fd 100644 --- a/src/useThrottledEffect/__docs__/story.mdx +++ b/src/useThrottledEffect/__docs__/story.mdx @@ -1,5 +1,5 @@ -import { Canvas, Meta, Story } from "@storybook/addon-docs/blocks"; -import { Example } from "./example.stories"; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './example.stories'; diff --git a/src/useThrottledState/__docs__/story.mdx b/src/useThrottledState/__docs__/story.mdx index 17433e881..3fb4e2953 100644 --- a/src/useThrottledState/__docs__/story.mdx +++ b/src/useThrottledState/__docs__/story.mdx @@ -1,5 +1,5 @@ -import {Canvas, Meta, Story} from '@storybook/addon-docs/blocks'; -import {Example} from './example.stories'; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './example.stories'; @@ -28,10 +28,9 @@ export function useThrottledState( - **initialState** _`S | (() => S)`_ - Initial state to pass to underlying `useSafeState`. - **delay** _`number`_ - Throttle delay. - **noTrailing** _`boolean`_ _(default: false)_ - If `noTrailing` is true, callback will only execute -every `delay` milliseconds, otherwise, callback will be executed once, after the last call. + every `delay` milliseconds, otherwise, callback will be executed once, after the last call. #### Return 0. **state** - current state. 1. **setState** - throttled state setter. - diff --git a/src/util/misc.ts b/src/util/misc.ts index 1e31e700e..1b15b2291 100644 --- a/src/util/misc.ts +++ b/src/util/misc.ts @@ -1,7 +1,9 @@ export function on( obj: T | null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...args: Parameters | [string, CallableFunction | null, ...any] + ...args: + | Parameters + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | [string, EventListenerOrEventListenerObject | CallableFunction, ...any] ): void { if (obj && obj.addEventListener) { obj.addEventListener(...(args as Parameters)); @@ -10,8 +12,10 @@ export function on( export function off( obj: T | null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...args: Parameters | [string, CallableFunction | null, ...any] + ...args: + | Parameters + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | [string, EventListenerOrEventListenerObject | CallableFunction, ...any] ): void { if (obj && obj.removeEventListener) { obj.removeEventListener(...(args as Parameters)); @@ -20,3 +24,7 @@ export function off( // eslint-disable-next-line @typescript-eslint/naming-convention export type PartialRequired = Omit & Required>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const hasOwnProperty = (obj: Record, property: string): boolean => + Object.prototype.hasOwnProperty.call(obj, property);