DEV Community

Cover image for Adding Keyboard Shortcuts to Your React App in better way
Lalit
Lalit

Posted on

Adding Keyboard Shortcuts to Your React App in better way

You might have seen some professional web apps like Google sheet, Figma or any other feature rich app. One thing that make these app's user experience better is use of keyboard interaction. You can also added these keyboard interactions in your react app. In this article, I will try to explain how you can build one in your app that will be cleaner, dev friendly and using best practices.

The Approach: A central Shortcut Manager

Imagine your app has a single, smart manager for all keyboard shortcuts. Its job is simple:

  1. Listen to every key pressed anywhere in the app
  2. Know all the available shortcuts and what they do
  3. Ignore keys pressed in text (or editable) fields (a critical detail!)
  4. Trigger the right action instantly

This is the core idea. Instead of having dozens of independent key listeners scattered across your components, you have one centralized brain with centralized registry of shortcuts. This makes everything easier: preventing conflicts, managing memory and keeping you code clean.

A Glimpse at the Implementation

This implementation will have two major part or files:

  1. A Context Provider (ShortcutProvider): This component wraps you entire application. It becomes the central registry that holds all the shortcuts and their functions.
  2. A Custom Hook (useShortcuts): This is how any component talks to the provider. It's simple API with two function: register and unregister. Which is simply like : "Hey manager, please register this shortcut for me and here is the function that I want to perform on triggering this shortcut" or "I am done with this shortcut, please unregister it."

Here is the beautiful part of using this pattern:

// Deep inside any component, you can easily add a shortcut.
function SaveButton() {
  const { register } = useShortcuts();

  // Register 'Ctrl+S' to save the document
  useEffect(() => {
    const handleSave = () => { /* Save logic here */ };
    register({ key: 's', modifiers: ['Ctrl'] }, handleSave);
    return () => unregister({ key: 's', modifiers: ['Meta']  }); // Cleanup!
  }, []);
Enter fullscreen mode Exit fullscreen mode

Simple isn't, the component doesn't need to know about global event listeners and you can also add more rigorous conflict logic in "ShortcutProvider". It just asks the user component to register its intent. The central manager handles the rest.

The payoff: Why is it a better approach

  • Clear Code
  • Using best practice, react hook pattern with context
  • Team Friendly, as it creates a standard, simple way for everyone on you team to add and remove shortcuts without stepping on each other's toes.

Here is the full code :

ShortcutsProvider.tsx

"use client";
import React, { createContext, useEffect, useRef } from "react";
import {
  Modifier,
  Shortcut,
  ShortcutHandler,
  ShortcutRegistry,
  ShortcutsContextType,
} from "./types";

export const ShortcutsContext = createContext<ShortcutsContextType>({
  register: () => {},
  unregister: () => {},
});

const normalizeShortcut = (shortcut: Shortcut): string => {
  const mods = shortcut.modifiers?.slice().sort() || []; // Sort alphabetically
  const key = shortcut.key.toUpperCase(); // Normalize case
  return [...mods, key].join("+");
};

const ShortcutProvider = ({ children }: { children: React.ReactNode }) => {
  // Registry for shortcut with key as shortcut combination and value as the handler
  const ShortcutRegisteryRef = useRef<ShortcutRegistry>(new Map());

  const register = (
    shortcut: Shortcut,
    handler: ShortcutHandler,
    override = false
  ) => {
    const ShortcutRegistery = ShortcutRegisteryRef.current;
    // before proceeding with logic let normalized the key
    // first we sort the modifiers from shortcut alphabetically
    // then make key uppercase for consistency

    const modifiers = shortcut.modifiers
      ? shortcut.modifiers.slice().sort()
      : [];
    const key = shortcut.key.toUpperCase();

    const normalizedKey = [...modifiers, key].join("+");

    // here checking for conflicts
    if (ShortcutRegistery.has(normalizedKey) && !override) {
      console.warn(
        `Conflict: "${normalizedKey}" is already registered for shortcut. Use override=true to replace or handle conflict.`
      );
      return;
    }

    ShortcutRegistery.set(normalizedKey, handler);
  };

  const unregister = (shortcut: Shortcut) => {
    // again normaizing the key, we are repeating this code so better to make function out of this
    const modifiers = shortcut.modifiers
      ? shortcut.modifiers.slice().sort()
      : [];
    const key = shortcut.key.toUpperCase();

    const normalizedKey = [...modifiers, key].join("+");
    ShortcutRegisteryRef.current.delete(normalizedKey);
  };

  const handleKeyDown = (event: KeyboardEvent) => {
    const target = event.target as HTMLElement;
    // this check is important as without this we wont be able to write in these inputs
    if (
      target.tagName === "INPUT" ||
      target.tagName === "TEXTAREA" ||
      target.isContentEditable
    ) {
      return;
    }

    const modifiers: Modifier[] = [];

    if (event.ctrlKey) modifiers.push("Ctrl");
    if (event.altKey) modifiers.push("Alt");
    if (event.shiftKey) modifiers.push("Shift");
    if (event.metaKey) modifiers.push("Meta");

    const key = event.key.toUpperCase();
    const normalizedKey = [...modifiers.sort(), key].join("+");

    const handler = ShortcutRegisteryRef.current.get(normalizedKey);

    if (handler) {
      event.preventDefault();
      handler(event);
    }
  };

  useEffect(() => {
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, []);

  return (
    <ShortcutsContext.Provider value={{ register, unregister }}>
      {children}
    </ShortcutsContext.Provider>
  );
};

export default ShortcutProvider;

Enter fullscreen mode Exit fullscreen mode

useShortcuts.tsx

import { useContext } from "react";
import { ShortcutsContext } from "./ShortcutsProvider";

const useShortcuts = () => {
  const shortcutContext = useContext(ShortcutsContext);

  if (!shortcutContext) {
    console.error("Shortcut context must be wrapped inside Shortcut provider");
  }

  return shortcutContext;
};

export default useShortcuts;
Enter fullscreen mode Exit fullscreen mode

types.ts

export type Modifier = string;
export type Key = string;

export interface Shortcut {
  key: Key;
  modifiers?: string[];
}

export type ShortcutHandler = (e: KeyboardEvent) => void;
export interface ShortcutsContextType {
  register: (shortcut: Shortcut, handler: ShortcutHandler) => void;
  unregister: (shortcut: Shortcut) => void;
}

export type ShortcutRegistry = Map<string, ShortcutHandler>;

Enter fullscreen mode Exit fullscreen mode

Main app component (using Next js here)

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <ShortcutProvider>{children}</ShortcutProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

You can find the complete solution with workable example on GitHub.

Let's Connect!

Enjoyed this breakdown? I share tips and insights on social media platforms. Follow me on X/Twitter. Feel free to star the project on GitHub and connect with me on LinkedIn.

Top comments (0)