Slim Down Your React Code with the useEventListener Hook

Learn how the useEventListener hook in React can help reduce code complexity while keeping your app's event handling clean and readable.

#react
#typescript
#hooks
Slim Down Your React Code with the useEventListener Hook
Picture by Lee Campbell

Among all the existing React custom hooks I've been working on in the past, the one I like the most is probably useEventListener.

This React hook is probably less famous than other custom React abstractions such as useLocalStorage or useToggle, yet it is a great example of how composition and re-usability in React matter.

Why do we need a custom hook for event listeners?

Event handling is a core concept for any Web App.

Do you know those beautiful search bar that opens on a combination of keyboard buttons? Or those progress bar that grows on top of your blog post once you scroll and read the content?

All of them, and many more features, are implemented in setting event listeners in your app. Without them, we could still create wonderful Web apps, although we might missing some magical features that help create engagement.

Setting an event listener in a React component

Let's say we have a blog post <ProgressBar /> component and we want to make it fill the page width as the user scrolls the page over the content. For this example, we need to listen for the scroll event.

To set this event listener, the component would probably look something like this:

tsx
function ProgressBar() {
const [scrollPosition, setPosition] = useState('0%');

useEffect(() => {
const handleScrollPosition = () => {
requestAnimationFrame(() => {
const scrollDistance = getScrollDistance();
setPosition(scrollDistance);
});
};

window.addEventListener('scroll', handleScrollPosition, { passive: true });

return () => {
window.removeEventListener('scroll', handleScrollPosition);
};
}, []);

return (
<div
className="fixed bg-gradient-to-r from-sky-300 to-sky-500 w-[--w-progress-scroll] h-1 z-50"
style={{ '--w-progress-scroll': scrollPosition }}
/>
);
}

Although a simple example, there are some details to look at and keep in mind:

  • We need to create a side effect to set an event listener once. In this case, we don't have any dependency, so it will be created only on component mount.
  • Once the listener is set, we should also handle the component unmount scenario. To prevent memory leaks in our app from forgotten ongoing event listeners, be sure to remove it using .removeEventListener() on the target element.

This feels short enough to say we don't need a custom hook for this. And you are right, there is no need to overengineer a project with tons of custom hooks.

However, if you work on a highly interactive app, you might need more event listeners even in the same component, and this can make your code less readable and maintainable in the long term.

Just imagine the app needs to react to 5–10 different key combinations, it can easily make the component code grow a lot.

useEventListener makes React event listeners easy

Let's take a look at my useEventListener implementation from the @mountain-ui/react-hooks library.

tsx
import { MutableRefObject, useEffect, useRef } from 'react';
import { hasWindow } from '@mountain-ui/utils';

export type TEvent = string | string[];
export type Target = Window | Document | MutableRefObject<HTMLElement>;

function getElement(target: Target) {
const targetIsRef = 'current' in target;
return targetIsRef ? target.current : target;
}

function useEventListener(
events: TEvent,
handler: EventListener,
target: Target = hasWindow() ? window : null,
options?: boolean | AddEventListenerOptions
): void {
// Create a list of events if a single string is passed
const eventList = Array.isArray(events) ? events : [events];
const serializedEventList = JSON.stringify(eventList);
const serializedOptions = JSON.stringify(options);

// Create a ref that stores the handler
const savedHandler = useRef(handler);

// Update ref.current value if handler changes.
// This allows our effect below to always get latest handler ...
// ... without us needing to pass it in effect deps array ...
// ... and potentially cause effect to re-run every render.
// Search for "Latest Ref pattern" to know more about the topic.
useEffect(() => {
savedHandler.current = handler;
}, [handler]);

useEffect(() => {
// Use window if no element is passed, otherwise default to null in SSR
const element = getElement(target);
// Make sure element supports addEventListener
const isSupported = element && element.addEventListener;
if (!isSupported) return;

const listener = (event: Event) => savedHandler.current && savedHandler.current(event);

// Add event listener
eventList.forEach(event => element.addEventListener(event, listener, options));

// Remove event listener on cleanup
return () => {
eventList.forEach(event => element.removeEventListener(event, listener, options));
};
}, [serializedEventList, target, serializedOptions]);
}

This version supports multiple events in a single call in case the handler is the same, so we can keep its usage even cleaner in our components.

If we examine the implementation:

  • getElement helps to resolve the target element or a given ref argument.
  • eventList, serializedEventList and serializedOptions are derived from the arguments to prepare the side effects definition.
  • savedHandler and the first useEffect call are used to allow our event listener side effect to always get the latest handler. This is a pattern known as Latest Ref pattern.
  • The final side effect creates the listener and adds/removes it in the lifecycle of this hook, regenerating it once some options or events in the list change.

As said, this wouldn't change the previous example's complexity, but if we look at an example where a component should react to many events, we'll notice the real benefits.

tsx
function TextEditor() {
useEventListener('keydown', event => event.key === 'ArrowUp' && moveCursorUp());
useEventListener('keydown', event => event.key === 'ArrowDown' && moveCursorDown());
useEventListener('keydown', event => event.key === 'Escape' && leaveEditor());
//...
}

This is already much shorter than how it would be without this abstraction.

If we go the extra mile, assume we also created a useKeyPress custom hook on top of useEventListener:

tsx
function TextEditor() {
useKeyPress('ArrowUp', () => moveCursorUp());
useKeyPress('ArrowDown', () => moveCursorDown());
useKeyPress('Escape', () => leaveEditor());
//...
}

Wrapping up

useEventListener it's great because it opens the door to many more custom hooks specialized in specific actions such as useKeyPress, useScroll, useClickOutside and many more, I hope you'll enjoy refactoring some legacy code where this snippet might help you! Cheers 🎉

Last updated: