Skip to content
1 change: 1 addition & 0 deletions src/hooks/useEventListener/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useEventListener } from './useEventListener.ts';
117 changes: 117 additions & 0 deletions src/hooks/useEventListener/useEventListener.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useRef } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';

import { useEventListener } from './useEventListener.ts';

describe('useEventListener', () => {
let handlerSpy: Mock;

beforeEach(() => {
handlerSpy = vi.fn();
});

it('should trigger window resize event', () => {
function TestComponent() {
useEventListener('resize', handlerSpy);

return <div>Resize the window</div>;
}

render(<TestComponent />);

global.dispatchEvent(new Event('resize'));

expect(handlerSpy).toHaveBeenCalled();
});

it('should trigger window scroll event', () => {
function TestComponent() {
useEventListener('scroll', handlerSpy);

return <div style={{ height: '100vh' }}>Scroll the window</div>;
}

render(<TestComponent />);

global.dispatchEvent(new Event('scroll'));

expect(handlerSpy).toHaveBeenCalled();
});

it('should trigger document visibilitychange event', () => {
function TestComponent() {
useEventListener('visibilitychange', handlerSpy, document);

return <div>Visibility Change</div>;
}

render(<TestComponent />);

document.dispatchEvent(new Event('visibilitychange'));

expect(handlerSpy).toHaveBeenCalled();
});

it('should trigger document click event', () => {
function TestComponent() {
useEventListener('click', handlerSpy, document);

return <div>Click anywhere in the document</div>;
}

render(<TestComponent />);

fireEvent.click(document);

expect(handlerSpy).toHaveBeenCalled();
});

it('should trigger element click event', () => {
function TestComponent() {
const buttonRef = useRef<HTMLButtonElement>(null);

useEventListener('click', handlerSpy, buttonRef);

return <button ref={buttonRef}>Click me</button>;
}

render(<TestComponent />);

fireEvent.click(screen.getByText('Click me'));

expect(handlerSpy).toHaveBeenCalled();
});

it('should trigger element focus event', () => {
function TestComponent() {
const inputRef = useRef<HTMLInputElement>(null);

useEventListener('focus', handlerSpy, inputRef);

return <input ref={inputRef} />;
}

render(<TestComponent />);

fireEvent.focus(screen.getByRole('textbox'));

expect(handlerSpy).toHaveBeenCalled();
});

it('should not throw if ref is null and does not register listener', () => {
function TestComponent() {
const nullRef = useRef<HTMLDivElement>(null);

useEventListener('click', handlerSpy, nullRef);

return <div>Test</div>;
}

render(<TestComponent />);

fireEvent.click(screen.getByText('Test'));

expect(handlerSpy).not.toHaveBeenCalled();
});
});
113 changes: 113 additions & 0 deletions src/hooks/useEventListener/useEventListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { RefObject, useEffect } from 'react';

import { usePreservedCallback } from '../usePreservedCallback/index.ts';

/**
* @description
* `useEventListener` is a React hook that allows you to easily add and clean up event listeners on various targets,
* such as `window`, `document`, HTML elements, or SVG elements.
* The listener is automatically updated with the latest handler on each render without reattaching,
* ensuring stable performance and correct behavior.
*
* @template KW - Event name type for `window` events, determining the corresponding event object type.
* @template KD - Event name type for `document` events, determining the corresponding event object type.
* @template KH - Event name type for HTML or SVG element events, determining the corresponding event object type.
* @template T - Type of the DOM element being referenced (default is `HTMLElement`, but can be an SVG element).
* @param {KW | KD | KH} eventName - The name of the event to listen for.
* @param {(event: WindowEventMap[KW] | DocumentEventMap[KD] | HTMLElementEventMap[KH] | SVGElementEventMap[KH]) => void} handler - The callback function that will be triggered when the event occurs.
* @param {RefObject<T | null> | Document} [element] - The target to attach the event listener to. Can be a React `ref` object or the `document`. If omitted or `undefined`, the listener is attached to the `window`.
* @param {boolean | AddEventListenerOptions} [options] - Optional parameters for the event listener such as `capture`, `once`, or `passive`.
*
* @example
* function WindowResize() {
* useEventListener('resize', (event) => {
* console.log('Window resized', event);
* });
*
* return <div>Resize the window and check the console.</div>;
* }
*
* @example
* function ClickButton() {
* const buttonRef = useRef<HTMLButtonElement>(null);
*
* useEventListener('click', (event) => {
* console.log('Button clicked', event);
* }, buttonRef);
*
* return <button ref={buttonRef}>Click me</button>;
* }
Comment on lines +30 to +39
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have any better use than just using the onClick function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jungpaeng

This example isn’t meant to suggest that useEventListener is better than onClick — it’s just a simple way to demonstrate how the hook works with a ref.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it will useful when using 'CustomEvent'

*
* @example
* function ScrollTracker() {
* const scrollRef = useRef<HTMLDivElement>(null);
*
* useEventListener('scroll', () => {
* console.log('Scroll event detected!');
* }, scrollRef, { passive: true });
*
* return (
* <div ref={scrollRef} style={{ height: '100px', overflowY: 'scroll' }}>
* <div style={{ height: '300px' }}>Scroll Me!</div>
* </div>
* );
* }
*
* @example
* function Document() {
* useEventListener('click', (event) => {
* console.log('Document clicked at coordinates', event.clientX, event.clientY);
* }, document);
Comment on lines +58 to +60
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation does not appear to be safe in an SSR (Server-Side Rendering) environment. Directly referencing client-side global variables such as document seems risky. We should look for a way to ensure safe operation even in SSR environments.

For example, when you need to attach handlers to client-side global variables like document or window, instead of referencing them directly, you can pass them as strings like 'document' or 'window'. Then, inside a useEffect, you can safely access them conditionally. This approach can help ensure stability in SSR environments.

Suggested change
* useEventListener('click', (event) => {
* console.log('Document clicked at coordinates', event.clientX, event.clientY);
* }, document);
* useEventListener('click', (event) => {
* console.log('Document clicked at coordinates', event.clientX, event.clientY);
* },
* // ensure that `document` is only referenced on the client side.
* 'document'
* );

*
* return <div>Click anywhere on the document and check the console for coordinates.</div>;
* }
*/
export function useEventListener<
K extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
T extends Element = K extends keyof HTMLElementEventMap ? HTMLElement : SVGElement,
>(
eventName: K,
handler: (event: HTMLElementEventMap[K] | SVGElementEventMap[K]) => void,
element: RefObject<T | null>,
options?: boolean | AddEventListenerOptions
): void;
export function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
element: Document,
options?: boolean | AddEventListenerOptions
): void;
export function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: undefined,
options?: boolean | AddEventListenerOptions
): void;
export function useEventListener<
KW extends keyof WindowEventMap,
KD extends keyof DocumentEventMap,
KH extends keyof HTMLElementEventMap & keyof SVGElementEventMap,
T extends HTMLElement | SVGElement = HTMLElement,
>(
eventName: KW | KD | KH,
handler: (
event: WindowEventMap[KW] | DocumentEventMap[KD] | HTMLElementEventMap[KH] | SVGElementEventMap[KH] | Event
) => void,
element?: RefObject<T | null> | Document,
options?: boolean | AddEventListenerOptions
) {
const preservedHandler = usePreservedCallback(handler);

useEffect(() => {
const targetElement =
element instanceof Document ? document : (element?.current ?? (element === undefined ? window : undefined));

if (!targetElement?.addEventListener) return;

const listener: typeof handler = event => preservedHandler(event);

targetElement.addEventListener(eventName, listener, options);

return () => targetElement.removeEventListener(eventName, listener, options);
}, [eventName, element, options, preservedHandler]);
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { useCounter } from './hooks/useCounter/index.ts';
export { useDebounce } from './hooks/useDebounce/index.ts';
export { useDebouncedCallback } from './hooks/useDebouncedCallback/index.ts';
export { useDoubleClick } from './hooks/useDoubleClick/index.ts';
export { useEventListener } from './hooks/useEventListener/index.ts';
export { useImpressionRef } from './hooks/useImpressionRef/index.ts';
export { useInputState } from './hooks/useInputState/index.ts';
export { useIntersectionObserver } from './hooks/useIntersectionObserver/index.ts';
Expand Down
Loading