-
Notifications
You must be signed in to change notification settings - Fork 53
feat(hooks): add 'useEventListener' #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9f1ed15
0ec54b9
2c56a00
985c6b4
5b5e0da
4fbfe9d
c4472a3
ebfe5b5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { useEventListener } from './useEventListener.ts'; |
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(); | ||
}); | ||
}); |
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>; | ||||||||||||||||||||
* } | ||||||||||||||||||||
* | ||||||||||||||||||||
* @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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||||||||||||||
* | ||||||||||||||||||||
* 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]); | ||||||||||||||||||||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 thanonClick
— it’s just a simple way to demonstrate how the hook works with a ref.There was a problem hiding this comment.
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'