React - My useEventListener Hook

Published: Oct 27, 2024

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 like PointerEvent, WheelEvent, and KeyboardEvent.

Hook Parameters:

  • eventType: A specific EventType 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 to window), which could be an HTMLElement, Document, or Window.
  • 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 as React.DependencyList, which is specifically designed for use in useEffect dependencies. This type is defined as readonly any[], but it's the standard and preferred way to handle dependency arrays in React with TypeScript.
  • options: Optional AddEventListenerOptions that configure the listener (e.g., { passive: true } to improve performance when scrolling).

handler and memoizedElement:

  • handler: Uses useCallback to memoize the callback, ensuring the same reference is used unless callback itself changes. This helps reduce unnecessary re-registrations of the event listener.
  • memoisedElement: Uses useMemo to memoize the element 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.