Skip to content

Commit

Permalink
feat: new hook useEventListener (#140)
Browse files Browse the repository at this point in the history
* feat: new hook `useEventListener`

* Fix some typos

Co-authored-by: Joe Duncko <[email protected]>
  • Loading branch information
xobotyi and JoeDuncko authored Jun 22, 2021
1 parent 5881c3f commit 9c5dce3
Show file tree
Hide file tree
Showing 10 changed files with 264 additions and 10 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,4 @@ export { useMediaQuery } from './useMediaQuery/useMediaQuery';

// Dom
export { useDocumentTitle, IUseDocumentTitleOptions } from './useDocumentTitle/useDocumentTitle';
export { useEventListener } from './useEventListener/useEventListener';
41 changes: 41 additions & 0 deletions src/useEventListener/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<Date>();
const [mounted, toggleMounted] = useToggle(true);

const ToggledComponent = () => {
useEventListener(
window,
'mousemove',
() => {
setState(new Date());
},
{ passive: true }
);

return <div>child component is mounted</div>;
};

return (
<div>
<div>
Below state is update on window&apos;s `mousemove` event.
<br />
You can unmount child component to ensure that event is unsubscribed when component
unmounted.
</div>

<br />
<div>{state ? `mouse last moved: ${state}` : 'mouse not moved yet'}</div>

<br />
<div>
{mounted && <ToggledComponent />}
<button onClick={() => toggleMounted()}>toggle component</button>
</div>
</div>
);
};
36 changes: 36 additions & 0 deletions src/useEventListener/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';

<Meta title="Dom/useEventListener" component={Example} />

# 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

<Canvas>
<Story story={Example} inline />
</Canvas>

## Reference

```ts
export function useEventListener<T extends EventTarget>(
target: ITargetOrTargetRef<T>,
...params:
| Parameters<T['addEventListener']>
| [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]`.
89 changes: 89 additions & 0 deletions src/useEventListener/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 13 additions & 0 deletions src/useEventListener/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
65 changes: 65 additions & 0 deletions src/useEventListener/useEventListener.ts
Original file line number Diff line number Diff line change
@@ -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 extends EventTarget> = T | null | RefObject<T> | MutableRefObject<T>;

/**
* 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<T extends EventTarget>(
target: ITargetOrTargetRef<T>,
...params:
| Parameters<T['addEventListener']>
| [string, EventListenerOrEventListenerObject | ((...args: any[]) => any), ...any]
): void {
// extract current target from ref object
const tgt: T =
target && hasOwnProperty(target, 'current')
? (target as MutableRefObject<T>).current
: (target as T);
const isMounted = useIsMounted();

// create static event listener
const listenerRef = useSyncedRef(params[1]);
const eventListener = useMemo<EventListener>(
() =>
// 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]]);
}
4 changes: 2 additions & 2 deletions src/useThrottledEffect/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -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';

<Meta title="Lifecycle/useThrottledEffect" component={Example} />

Expand Down
7 changes: 3 additions & 4 deletions src/useThrottledState/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -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';

<Meta title="State/useThrottledState" component={Example} />

Expand Down Expand Up @@ -28,10 +28,9 @@ export function useThrottledState<S>(
- **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.

16 changes: 12 additions & 4 deletions src/util/misc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export function on<T extends EventTarget>(
obj: T | null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: Parameters<T['addEventListener']> | [string, CallableFunction | null, ...any]
...args:
| Parameters<T['addEventListener']>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| [string, EventListenerOrEventListenerObject | CallableFunction, ...any]
): void {
if (obj && obj.addEventListener) {
obj.addEventListener(...(args as Parameters<HTMLElement['addEventListener']>));
Expand All @@ -10,8 +12,10 @@ export function on<T extends EventTarget>(

export function off<T extends EventTarget>(
obj: T | null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: Parameters<T['removeEventListener']> | [string, CallableFunction | null, ...any]
...args:
| Parameters<T['removeEventListener']>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| [string, EventListenerOrEventListenerObject | CallableFunction, ...any]
): void {
if (obj && obj.removeEventListener) {
obj.removeEventListener(...(args as Parameters<HTMLElement['removeEventListener']>));
Expand All @@ -20,3 +24,7 @@ export function off<T extends EventTarget>(

// eslint-disable-next-line @typescript-eslint/naming-convention
export type PartialRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const hasOwnProperty = (obj: Record<any, any>, property: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, property);

0 comments on commit 9c5dce3

Please sign in to comment.