Skip to content

Commit ac3523a

Browse files
committed
feat(hooks): add 'useEventListener'
1 parent 94f8826 commit ac3523a

File tree

4 files changed

+216
-0
lines changed

4 files changed

+216
-0
lines changed

src/hooks/useEventListener/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useEventListener } from './useEventListener.ts';
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { useRef } from 'react';
2+
import { fireEvent, render, screen } from '@testing-library/react';
3+
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
4+
5+
import { useEventListener } from './useEventListener.ts';
6+
7+
describe('useEventListener Hook', () => {
8+
let handlerSpy: Mock;
9+
10+
beforeEach(() => {
11+
handlerSpy = vi.fn();
12+
});
13+
14+
it('should trigger window resize event', () => {
15+
function TestComponent() {
16+
useEventListener('resize', handlerSpy);
17+
18+
return <div>Resize the window</div>;
19+
}
20+
21+
render(<TestComponent />);
22+
23+
global.dispatchEvent(new Event('resize'));
24+
25+
expect(handlerSpy).toHaveBeenCalled();
26+
});
27+
28+
it('should trigger window scroll event', () => {
29+
function TestComponent() {
30+
useEventListener('scroll', handlerSpy);
31+
32+
return <div style={{ height: '100vh' }}>Scroll the window</div>;
33+
}
34+
35+
render(<TestComponent />);
36+
37+
global.dispatchEvent(new Event('scroll'));
38+
39+
expect(handlerSpy).toHaveBeenCalled();
40+
});
41+
42+
it('should trigger document visibilitychange event', () => {
43+
function TestComponent() {
44+
useEventListener('visibilitychange', handlerSpy, document);
45+
46+
return <div>Visibility Change</div>;
47+
}
48+
49+
render(<TestComponent />);
50+
51+
document.dispatchEvent(new Event('visibilitychange'));
52+
53+
expect(handlerSpy).toHaveBeenCalled();
54+
});
55+
56+
it('should trigger document click event', () => {
57+
function TestComponent() {
58+
useEventListener('click', handlerSpy, document);
59+
60+
return <div>Click anywhere in the document</div>;
61+
}
62+
63+
render(<TestComponent />);
64+
65+
fireEvent.click(document);
66+
67+
expect(handlerSpy).toHaveBeenCalled();
68+
});
69+
70+
it('should trigger element click event', () => {
71+
function TestComponent() {
72+
const buttonRef = useRef<HTMLButtonElement>(null);
73+
74+
useEventListener('click', handlerSpy, buttonRef);
75+
76+
return <button ref={buttonRef}>Click me</button>;
77+
}
78+
79+
render(<TestComponent />);
80+
81+
fireEvent.click(screen.getByText('Click me'));
82+
83+
expect(handlerSpy).toHaveBeenCalled();
84+
});
85+
86+
it('should trigger element focus event', () => {
87+
function TestComponent() {
88+
const inputRef = useRef<HTMLInputElement>(null);
89+
90+
useEventListener('focus', handlerSpy, inputRef);
91+
92+
return <input ref={inputRef} />;
93+
}
94+
95+
render(<TestComponent />);
96+
97+
fireEvent.focus(screen.getByRole('textbox'));
98+
99+
expect(handlerSpy).toHaveBeenCalled();
100+
});
101+
it('should not throw if ref is null and does not register listener', () => {
102+
function TestComponent() {
103+
const nullRef = useRef<HTMLDivElement>(null);
104+
105+
useEventListener('click', handlerSpy, nullRef);
106+
107+
return <div>Test</div>;
108+
}
109+
110+
render(<TestComponent />);
111+
112+
fireEvent.click(document);
113+
114+
expect(handlerSpy).not.toHaveBeenCalled();
115+
});
116+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { RefObject, useEffect } from 'react';
2+
3+
import { usePreservedCallback } from '../usePreservedCallback/index.ts';
4+
5+
/**
6+
* @description
7+
* `useEventListener` is a React hook that simplifies adding and cleaning up event listeners on various targets
8+
* such as `window`, `document`, HTML elements, or SVG elements.
9+
* It automatically updates the handler without needing to reattach the event listener on every render,
10+
* ensuring stable performance and correct behavior.
11+
*
12+
* @template KW - The type of event for window events.
13+
* @template KD - The type of event for document events.
14+
* @template KH - The type of event for HTML or SVG element events.
15+
* @template T - The type of the DOM element (default is `HTMLElement`).
16+
* @param {KW | KD | KH} eventName - The name of the event to listen for.
17+
* @param {(event: WindowEventMap[KW] | DocumentEventMap[KD] | HTMLElementEventMap[KH] | SVGElementEventMap[KH]) => void} handler - The callback function to execute when the event is triggered.
18+
* @param {RefObject<T | null> | Document} [element] - A React ref object targeting the element to attach the event listener to, or a `Document` object to attach directly to the document. Defaults to `window` if not provided.
19+
* @param {boolean | AddEventListenerOptions} [options] - Optional parameters for the event listener, such as `capture`, `once`, or `passive`.
20+
*
21+
* @returns {void} This hook does not return anything.
22+
*
23+
* @example
24+
* function WindowResize() {
25+
* useEventListener('resize', (event) => {
26+
* console.log('Window resized', event);
27+
* });
28+
*
29+
* return <div>Resize the window and check the console.</div>;
30+
* }
31+
*
32+
* @example
33+
* function ClickButton() {
34+
* const buttonRef = useRef<HTMLButtonElement>(null);
35+
*
36+
* useEventListener('click', (event) => {
37+
* console.log('Button clicked', event);
38+
* }, buttonRef);
39+
*
40+
* return <button ref={buttonRef}>Click me</button>;
41+
* }
42+
*
43+
* @example
44+
* function Document() {
45+
* useEventListener('click', (event) => {
46+
* console.log('Document clicked at coordinates', event.clientX, event.clientY);
47+
* }, document);
48+
*
49+
* return <div>Click anywhere on the document and check the console for coordinates.</div>;
50+
* }
51+
*/
52+
export function useEventListener<
53+
K extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
54+
T extends Element = K extends keyof HTMLElementEventMap ? HTMLElement : SVGElement,
55+
>(
56+
eventName: K,
57+
handler: ((event: HTMLElementEventMap[K]) => void) | ((event: SVGElementEventMap[K]) => void),
58+
element: RefObject<T | null>,
59+
options?: boolean | AddEventListenerOptions
60+
): void;
61+
export function useEventListener<K extends keyof DocumentEventMap>(
62+
eventName: K,
63+
handler: (event: DocumentEventMap[K]) => void,
64+
element: Document,
65+
options?: boolean | AddEventListenerOptions
66+
): void;
67+
export function useEventListener<K extends keyof WindowEventMap>(
68+
eventName: K,
69+
handler: (event: WindowEventMap[K]) => void,
70+
element?: undefined,
71+
options?: boolean | AddEventListenerOptions
72+
): void;
73+
export function useEventListener<
74+
KW extends keyof WindowEventMap,
75+
KD extends keyof DocumentEventMap,
76+
KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
77+
T extends HTMLElement | SVGAElement = HTMLElement,
78+
>(
79+
eventName: KW | KD | KH,
80+
handler: (event: WindowEventMap[KW] | HTMLElementEventMap[KH] | DocumentEventMap[KD] | Event) => void,
81+
element?: RefObject<T | null> | Document,
82+
options?: boolean | AddEventListenerOptions
83+
) {
84+
const preservedHandler = usePreservedCallback(handler);
85+
86+
useEffect(() => {
87+
const targetElement =
88+
element instanceof Document ? document : (element?.current ?? (element === undefined ? window : undefined));
89+
90+
if (!targetElement?.addEventListener) return;
91+
92+
const listener: typeof handler = event => preservedHandler(event);
93+
94+
targetElement.addEventListener(eventName, listener, options);
95+
96+
return () => targetElement.removeEventListener(eventName, listener, options);
97+
}, [eventName, element, options, preservedHandler]);
98+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { useCounter } from './hooks/useCounter/index.ts';
99
export { useDebounce } from './hooks/useDebounce/index.ts';
1010
export { useDebouncedCallback } from './hooks/useDebouncedCallback/index.ts';
1111
export { useDoubleClick } from './hooks/useDoubleClick/index.ts';
12+
export { useEventListener } from './hooks/useEventListener/index.ts';
1213
export { useImpressionRef } from './hooks/useImpressionRef/index.ts';
1314
export { useInputState } from './hooks/useInputState/index.ts';
1415
export { useIntersectionObserver } from './hooks/useIntersectionObserver/index.ts';

0 commit comments

Comments
 (0)