There's a little bit more to managing an innocent little thing like clicking on a button, or hovering over an element or resizing a browser. So all too often I forget to clean up the event in React, or what some of the options might be as I'm too eager to get on to coding the more interesting features of my app.
To solve that problem once and for all, I have created a handy typesafe React Hook to wrap all this up for me. It's got room for expansion and improvement, which I will visit in the future, but right now this is working very nicely for me.
My useEventListener
Hook is intended to manage DOM event listeners for various elements in a React application. By abstracting away the setup and cleanup of event listeners, this Hook makes it easy for me to:
- Specify an event type and callback function.
- Attach the listener to a specified element (defaulting to the
window
). - Handle event listeners declaratively, so they can adapt to changing dependencies and component re-renders.
import React, { useEffect, useCallback, useMemo } from 'react'
enum EventType {
PointerMove = 'pointermove',
Wheel = 'wheel',
Keyup = 'keyup',
Keydown = 'keydown',
PointerDown = 'pointerdown',
PointerUp = 'pointerup',
Resize = 'resize',
}
type EventCallbackArgType<T extends PointerEvent | WheelEvent | KeyboardEvent> =
(e: T) => void
export const useEventListener = <
T extends PointerEvent | WheelEvent | KeyboardEvent,
E extends HTMLElement | Document | Window = Window,
>(
eventType: EventType,
callback: EventCallbackArgType<T>,
element: E = window as unknown as E,
dependencies: React.DependencyList = [],
options?: AddEventListenerOptions
) => {
const handler = useCallback(
(e: Event) => {
callback(e as T)
},
[callback]
)
const memoisedElement = useMemo(() => element, [element])
useEffect(() => {
memoisedElement.addEventListener(eventType, handler, options)
return () =>
memoisedElement.removeEventListener(eventType, handler, options)
}, [eventType, memoizedElement, handler, options, ...(dependencies || [])])
}
Type Definitions:
EventType
: Enumerates common DOM event types such as pointer movements, mouse scroll (wheel) events, key presses, and window resizing.EventCallbackArgType
: Defines the structure of the callback function, which takes a generic event type parameter (T
), supporting events likePointerEvent
,WheelEvent
, andKeyboardEvent
.
Hook Parameters:
eventType
: A specificEventType
enum value that defines the event to listen for (e.g.,EventType.PointerMove
).callback
: A function invoked whenever the specified event is triggered.element
: The target element for the listener (defaulting towindow
), which could be anHTMLElement
,Document
, orWindow
.- When TypeScript is trying to assign the default value (window) to a generic type E, it can't guarantee that E is always compatible with Window because E could also be HTMLElement or Document. I cast this to unknown first and then convert it to E, telling TypeScript that I'm assigning it to window and handling it as a valid E type.
dependencies
: An optional dependency array to control re-creation of the event listener if certain values change.- I define this type
dependencies
asReact.DependencyList
, which is specifically designed for use inuseEffect
dependencies. This type is defined asreadonly any[]
, but it's the standard and preferred way to handle dependency arrays in React with TypeScript. options
: OptionalAddEventListenerOptions
that configure the listener (e.g.,{ passive: true }
to improve performance when scrolling).
handler
and memoizedElement
:
handler
: UsesuseCallback
to memoize the callback, ensuring the same reference is used unlesscallback
itself changes. This helps reduce unnecessary re-registrations of the event listener.memoisedElement
: UsesuseMemo
to memoize theelement
to prevent re-creation across renders, which optimises the Hook’s efficiency.
Mount and cleanup:
useEffect
: this manages the event listener’s lifecycle. It attaches the event listener when the component mounts or when dependencies change, and cleans up by removing it when the component unmounts or dependencies update.
In Conclusion
Now I've got a really convenient way of handling events, I'll probably start forgetting how to actually write them anymore 😄 as I put this hook into practice.
I'm really happy with the features of this hook, this will help me remember what I need to add when setting up events whilst taking away the tedious tasks. I particularly like how this handles cleanup and dependencies and options which are often overlooked.
The TypeScript benefits for this useEventListener Hook define generics for event types (T) and elements (E), ensuring compatibility between event types (e.g., PointerEvent, WheelEvent) and target elements (HTMLElement, Document, or Window). Using an enum for common event types improves code readability and prevents errors from misspelled event strings. The EventCallbackArgType ensures that the callback matches the event type, providing autocompletion and validation when I'm working in VS Code.
Lets see some examples in action...
Tracking Mouse Movements
This example demonstrates listening for mouse movement events, updating state based on the cursor's position on the page.
import React, { useState } from 'react'
import { useEventListener} from './useEventListener'
export const CursorTracker = () => {
const [position, setPosition] = useState({ x: 0, y: 0 })
useEventListener(EventType.PointerMove, (e: PointerEvent) => {
setPosition({ x: e.clientX, y: e.clientY })
});
return (
<div>
<p>Cursor Position: X - {position.x}, Y - {position.y}</p>
</div>
)
}
Wheel Event on a Specific DOM Element with Passive Listener
This example demonstrates using the hook to handle a wheel
event on a specific div
element, with the passive
option enabled to improve scroll performance.
import { useRef, useState } from 'react'
import { useEventListener, EventType } from './useEventListener'
export const WheelEvent = () => {
const [scrollAmount, setScrollAmount] = useState(0)
const scrollRef = useRef<HTMLDivElement>(null)
useEventListener(
EventType.Wheel,
(e: WheelEvent) => {
setScrollAmount(prev => prev + e.deltaY)
},
scrollRef.current,
[],
{ passive: true } // Passive event listener for better scroll performance
)
return (
<div>
<h2>Wheel Scroll Amount: {scrollAmount}</h2>
<div
ref={scrollRef}
style={{ width: '300px', height: '200px', overflow: 'auto' }}
>
<p>Scroll inside this box with your mouse wheel.</p>
<p>Scroll Amount: {scrollAmount}</p>
</div>
</div>
)
}
Resize Event Listener for Window
This example demonstrates using the resize
event to dynamically adjust the layout based on window size.
import { useState } from 'react'
import { useEventListener, EventType } from './useEventListener'
export const ResizeEvent = () => {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})
useEventListener(EventType.Resize, () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
})
})
return (
<div>
<h2>Window Size</h2>
<p>Width: {windowSize.width}px</p>
<p>Height: {windowSize.height}px</p>
</div>
)
}
Handling Multiple Keyboard Events
You can reuse the hook for different event types, such as keyup
and keydown
, to handle keyboard interactions. In this case the keyup
and keydown
events are handled separately. The component tracks which key was pressed and whether the Shift key is pressed.
import { useState } from 'react'
import { useEventListener, EventType } from './useEventListener'
export const KeyboardEvent = () => {
const [lastKey, setLastKey] = useState<string | null>(null)
const [isShiftPressed, setShiftPressed] = useState(false)
// Listening for 'keyup' event
useEventListener(EventType.Keyup, (e: KeyboardEvent) => {
setLastKey(e.key)
setShiftPressed(e.shiftKey)
})
// Listening for 'keydown' event
useEventListener(EventType.Keydown, (e: KeyboardEvent) => {
setShiftPressed(e.shiftKey)
})
return (
<div>
<h2>Keyboard Event Listener</h2>
<p>Last Key Pressed: {lastKey}</p>
<p>Shift Key Pressed: {isShiftPressed ? 'Yes' : 'No'}</p>
</div>
)
}
How could I expand this Hook?
Extend for other events:
I could expand my EventType enum to include further events, like touch or orientationchange for example:
enum EventType {
...previous events...
Orientationchange = 'orientationchange',
TouchStart = 'touchstart',
TouchMove = 'touchmove',
TouchEnd = 'touchend',
TouchCancel = 'touchcancel',
}
Debouncing or Throttling the Event Listener
For some events, such as resize
or scroll
, firing the event listener too frequently can degrade performance. Adding built-in support for debouncing or throttling the event handler can help.
Improved Error Handling for Non-DOM Environments
If this hook is used in a non-DOM environment (like server-side rendering), I could include a simple check to prevent attaching event listeners in these cases. This is useful for React applications like Next.js.
Well...there's my homework I guess.
Thanks for reading and look out for my updated Hook when I post it.