Creating UI components like dialog, modal, or drawers mostly requires adding keyboard accessibilities like closing them when ESC (escape) key is pressed, and doing so may require you to attach an event listener on Window object for keyup
event inside use useEffect
hook and as well removing the event listener when the component is destroyed.
So may end up having something like this below where ever you need a global event
useEffect(() => {
const onESC = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
closeModal();
}
};
window.addEventListener("keyup", onESC, false);
return () => {
window.addEventListener("keyup", onESC, false);
};
}, []);
And I really don't like repeating alike code whenever possible, so let see we can hide most of this code since the only part that might change in different components will be the event handler
const onESC = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
closeModal();
}
}
So let's start by extracting this to its own component
// ~/hooks/useGlobalDOMEvents.ts
export default function useGlobalDOMEvents() {
useEffect(() => {
const onESC = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
closeModal();
}
};
window.addEventListener("keyup", onESC, false);
return () => {
window.addEventListener("keyup", onESC, false);
};
}, []);
}
Now our main goal is to make this function to accept multiple events and it's handlers, so let's define the type for our props
type Props = {
[key in keyof WindowEventMap]?: EventListenerOrEventListenerObject;
};
export default function useGlobalDOMEvents(props:Props) {
useEffect(() => {
const onESC = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
closeModal();
}
};
window.addEventListener("keyup", onESC, false);
return () => {
window.addEventListener("keyup", onESC, false);
};
}, []);
}
The props type with dynamic property keys will be very helpful for our editor autocomplete
Now let's refactor the useEffect
block to attach events dynamically based on our props properties
export default function useGlobalDOMEvents(props: Props) {
useEffect(() => {
for (let [key, func] of Object.entries(props)) {
window.addEventListener(key, func, false);
}
};
}, []);
}
and we have to make sure will remove the event listener once the component is destroyed
export default function useGlobalDOMEvents(props: Props = {}) {
useEffect(() => {
for (let [key, func] of Object.entries(props)) {
window.addEventListener(key, func, false);
}
return () => {
for (let [key, func] of Object.entries(props)) {
window.removeEventListener(key, func, false);
}
};
}, []);
}
and full code will look like this
// ~/hooks/useGlobalDOMEvents.ts
import { useEffect } from "react";
type Props = {
[key in keyof WindowEventMap]?: EventListenerOrEventListenerObject;
};
export default function useGlobalDOMEvents(props: Props) {
useEffect(() => {
for (let [key, func] of Object.entries(props)) {
window.addEventListener(key, func, false);
}
return () => {
for (let [key, func] of Object.entries(props)) {
window.removeEventListener(key, func, false);
}
};
}, []);
}
and usage will look like this
export default function Drawer(props: DrawerProps) {
const { children, open, title, onClose } = props;
useGlobalDOMEvents({
keyup(ev: KeyboardEvent) {
if (ev.key === "Escape") {
onClose();
}
},
});
[...]
}
I hope you find this helpful.
Top comments (4)
Taking some inspiration from this post when I stumbled upon it in 2024, I made a different implementation that solves the issues that @olliepugh pointed out below:
Hook:
Usage example:
Firstly, great hook, exactly what I needed, but I do want to suggest a slight change!
When I was trying to make use of ev.key, TypeScript was not happy, as it was not expecting
EventListenerOrEventListenerObject
to contain akey
property.If you change the props type to be
This sets sets the param in the callback to be the correct type specific to the event.
But that resulted in needing to cast the callback as an EventListener in the useEffect
window.addEventListener(key, func as EventListener, false);
Use of that
as
isn't great, but couldn't get any further with my limited TypeScript knowledge! Would love your thoughts on this?I guess if we are happy to be using
as
's then it would make most sense to solve my initial problem by just using as KeyboardEvent in the callback?Very happy to be told, nah this isn't the right apporach! 🤣
Extremely useful hook, however I've noticed one thing. In your first code block shouldn't it say:
return () => {
window.removeEventListener("keyup", onESC, false);
};
Not
addEventListener
.Useful when a mobile user needs to close boxes simply by clicking out of them and you don't want to implement It in every single clickable element. Nice hook.