DEV Community

Akuma Isaac Akuma
Akuma Isaac Akuma

Posted on • Edited on

Managing global DOM events in React with Hooks

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);
  };
}, []);


Enter fullscreen mode Exit fullscreen mode

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();
     }
}


Enter fullscreen mode Exit fullscreen mode

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);
    };
  }, []);
}


Enter fullscreen mode Exit fullscreen mode

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);
    };
  }, []);
}


Enter fullscreen mode Exit fullscreen mode

The props type with dynamic property keys will be very helpful for our editor autocomplete

Screenshot

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);
    }
    };
  }, []);
}


Enter fullscreen mode Exit fullscreen mode

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);
      }
    };
  }, []);
}



Enter fullscreen mode Exit fullscreen mode

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);
      }
    };
  }, []);
}



Enter fullscreen mode Exit fullscreen mode

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();
      }
    },
  });

  [...]
}


Enter fullscreen mode Exit fullscreen mode

I hope you find this helpful.

Top comments (4)

Collapse
 
skrebergene profile image
Lars

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:

export function useGlobalDOMEvent<K extends keyof WindowEventMap>(
  type: K,
  eventHandler: (e: WindowEventMap[K]) => void
) {
  useEffect(() => {
    window.addEventListener<K>(type, eventHandler);

    return () => {
      window.removeEventListener(type, eventHandler);
    };
  }, [type, eventHandler]);
}
Enter fullscreen mode Exit fullscreen mode

Usage example:

useGlobalDOMEvent("keydown", (e) => {
    console.log(e.key);
  });
Enter fullscreen mode Exit fullscreen mode
Collapse
 
olliepugh profile image
Ollie Pugh

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 a key property.

If you change the props type to be

type EventListenerWithType<T extends keyof WindowEventMap> = (
  event: WindowEventMap[T]
) => void;

type Props = {
  [key in keyof WindowEventMap]?: EventListenerWithType<key>;
};
Enter fullscreen mode Exit fullscreen mode

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! 🤣

Collapse
 
dthornes profile image
dthornes

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.

Collapse
 
muriloux profile image
Murilo Melo

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.