How to Build a Custom React Hook to Listen for Keyboard Events
I recently built a neat little slide-out menu bar for an improved mobile viewing experience on my personal website.
This is a compressed gif; it is way smoother in action.
Part of my quest to make my work as accessible as possible includes navigating my site nicely using keyboard events. So, I decided to add a little keyboard shortcut to close my open menu when the Escape
key is pressed.
Instead of jumping to a library that would implement this trivial change, I opted to build out this functionality myself, which gave me more control over exactly how it would work.
The end result is this simple useCloseMenuOnEscape
hook that takes in values based on my isMenuOpen
value and my toggleMenu
function.
export function Navigation() { const { isMenuOpen, toggleMenu } = useLayoutStore(); useCloseMenuOnEscape({ isMenuOpen, toggleMenu }); return <>The part that is irrelevant to this post</>; }
Under the Hook - Excuse the Pun
Let's dive into my useCloseMenuOnEscape
hook:
import { useEffect } from "react"; interface UseCloseMenuOnEscapeArgs { isMenuOpen: boolean; toggleMenu: () => void; } export function useCloseMenuOnEscape({ isMenuOpen, toggleMenu, }: UseCloseMenuOnEscapeArgs) { useEffect(() => { if (!isMenuOpen) { return; } function keyDownHandler(e: globalThis.KeyboardEvent) { if (isMenuOpen && e.key === "Escape") { e.preventDefault(); toggleMenu(); } } document.addEventListener("keydown", keyDownHandler); return () => { document.removeEventListener("keydown", keyDownHandler); }; }, [isMenuOpen]); }
As you can see, it's actually all just a simple useEffect
hook that does the following:
- It runs whenever the
isMenuOpen
prop changes. - If the
isMenuOpen
prop isfalse
, it doesn't do anything. - If the
isMenuOpen
prop istrue
, it adds an event listener that will call thetoggleMenu
function ifEscape
is pressed. - If the component that this hook is running on is unmounted, or the
isMenuOpen
prop changes, the event listener is removed.
This logic extraction to a custom hook is a great way to contain specific business logic in its own module. As you can see from my code snippet of the Navigation
component above, it is much easier to read what is going on using this abstraction.
A More General Use Case Hook
My use case above was rather specific, but if you need to create a more generalized reusable hook, you could create this:
import { useEffect } from "react"; interface UseKeyboardShortcutArgs { key: string onKeyPressed: () => void; } export function useKeyboardShortcut({ key, onKeyPressed }: UseCloseMenuOnEscapeArgs) { useEffect(() => { function keyDownHandler(e: globalThis.KeyboardEvent) { if (e.key === key) { e.preventDefault(); onKeyPressed(); } } document.addEventListener("keydown", keyDownHandler); return () => { document.removeEventListener("keydown", keyDownHandler); }; }, []); }
In this scenario, you have a hook that you can use that accepts a key
prop used to define what keyboard key you're listening for. It also accepts the onKeyPressed
, which defines what will run if the specified key
is pressed.
You can run this hook in your component like this:
function MyComponent() { useKeyboardShortcut({ key: "Enter", onKeyPressed: () => console.log("Enter was pressed!"), }) return <>whatever irrelevant thing you want to render</> }
In this simple example, whenever MyComponent
is mounted, an event listener is added that listens for the key Enter
. Once Enter
is pressed, it will run onKeyPressed
, which in this case is a console log that prints out "Enter was pressed!"
.
Conclusion
I hope this post has given you insight into how you can create your own keyboard event listening hooks rather than jumping to a library.
Building your own solutions in this context gives you a finer grain of control over what you'd like to happen without adding another dependency to your project.