DEV Community

Hasnaat Iftikhar
Hasnaat Iftikhar

Posted on

Building a Centralized Keyboard Shortcut System in React: A Priority-Based Approach

Building a Centralized Keyboard Shortcut System in React: A Priority-Based Approach

Have you ever pressed Escape in your React app and wondered why nothing happened? Or worse, the wrong thing happened? Maybe you had a modal open, but pressing Escape closed something else entirely. Or perhaps you had multiple panels open, and Escape closed the wrong one first.

I recently ran into this exact problem while working on a complex React application with nested panels, modals, and various UI components. Each component was handling keyboard events independently, leading to conflicts, unpredictable behavior, and a frustrating user experience.

After some trial and error, I built a centralized keyboard shortcut system using a priority-based approach. It solved all my problems, and I think it might help you too.

The Problem with Scattered Handlers

Let me show you what I was dealing with. Initially, I had keyboard handlers scattered across different components:

// ComponentA.tsx - This component handles Escape key to close Panel A
useEffect(() => {
  // Create a function that will handle keyboard events
  const handler = (e) => { 
    // Check if the pressed key is Escape
    if (e.key === 'Escape') { 
      // If yes, close Panel A
      closePanelA(); 
    } 
  };
  // Register this handler to listen for keydown events on the entire document
  document.addEventListener('keydown', handler);
  // Cleanup: Remove the listener when component unmounts
  return () => document.removeEventListener('keydown', handler);
}, []); // Empty array means this effect runs only once when component mounts

// ComponentB.tsx - This component ALSO handles Escape key to close Panel B
useEffect(() => {
  // Another handler for the same Escape key
  const handler = (e) => { 
    if (e.key === 'Escape') { 
      closePanelB(); // This closes Panel B instead
    } 
  };
  document.addEventListener('keydown', handler);
  return () => document.removeEventListener('keydown', handler);
}, []);
Enter fullscreen mode Exit fullscreen mode

This approach has several problems:

  1. No clear execution order - When both panels are open, which handler runs first? It's unpredictable. The browser might call them in any order, or both might execute, causing confusion.
  2. Conflicts - Multiple handlers might try to handle the same key, causing unexpected behavior. Both panels might close at once, or neither might close.
  3. Hard to maintain - Adding a new keyboard shortcut means finding the right component and hoping it doesn't conflict with existing ones. You have to search through multiple files.
  4. Difficult to debug - When something goes wrong, you have to check multiple files to understand what's happening. There's no single place to see all keyboard shortcuts.

In my case, I had a complex UI with nested panels, modals, and various overlays. The Escape key needed to close things in a specific order - the most nested panel first, then work its way out. But with scattered handlers, this was impossible to guarantee.

The Solution: A Centralized Registry

Instead of having handlers scattered everywhere, I created a single registry (think of it as a list or database) that contains all keyboard shortcuts. Each shortcut has:

  • A key (or key combination) - like "Escape" or "Ctrl+K"
  • A condition that determines when it should be active - a function that checks if the shortcut should work right now
  • A handler function that executes the action - what to do when the shortcut is pressed
  • A priority number (lower = higher priority) - which shortcut should run first if multiple match

Here's the basic structure:

// This interface (TypeScript type definition) describes what each keyboard shortcut looks like
export interface Keybinding {
  // The main key being pressed (e.g., "Escape", "Enter", "k")
  key: string;

  // Optional modifier keys (like Ctrl, Shift, Alt, Cmd)
  // The "?" means these are optional - you don't have to provide them
  modifiers?: {
    meta?: boolean; // Cmd key on Mac (⌘)
    ctrl?: boolean; // Ctrl key on Windows/Linux
    shift?: boolean; // Shift key
    alt?: boolean; // Alt key (Option on Mac)
  };

  // A function that returns true/false to determine if this shortcut should be active
  // It receives the current app context (what's open, what's closed, etc.)
  condition: (context: AppContext) => boolean;

  // The function that runs when this shortcut is pressed
  // It receives: the keyboard event, current context, and available actions
  handler: (event: KeyboardEvent, context: AppContext, actions: Actions) => void;

  // Should we prevent the browser's default behavior for this key?
  // For example, prevent Escape from closing the browser tab
  preventDefault?: boolean;

  // Priority number: lower number = higher priority (runs first)
  // Example: priority 1 runs before priority 2
  priority?: number;
}
Enter fullscreen mode Exit fullscreen mode

The key insight here is the priority system. When a key is pressed, the system evaluates all keybindings in priority order (lowest number first). The first matching keybinding wins and gets executed. This ensures predictable behavior - you always know which shortcut will run first.

Building the Context System

Before we can evaluate conditions (check if shortcuts should be active), we need to know the current state of the application. I created a context object (a simple JavaScript object) that contains boolean flags (true/false values) representing the current UI state:

// This type definition describes what our context object looks like
// It's like a snapshot of what's currently open or closed in the app
type AppContext = {
  IS_MODAL_OPEN: boolean;      // Is a modal dialog open? (true/false)
  IS_SIDEBAR_OPEN: boolean;    // Is the sidebar open? (true/false)
  IS_PANEL_A_OPEN: boolean;    // Is Panel A open? (true/false)
  IS_PANEL_B_OPEN: boolean;    // Is Panel B open? (true/false)
  // ... more flags for other UI elements
};
Enter fullscreen mode Exit fullscreen mode

I compute this context from my application state using a custom hook (a reusable React function):

// This custom hook creates and returns the current app context
export const useAppShortcuts = () => {
  // Get the current state from our app store (state management)
  // The "??" operator means "if this is null/undefined, use false instead"
  const isModalOpen = useAppStore((state) => state.MODAL?.isOpen ?? false);
  const isSidebarOpen = useAppStore((state) => state.SIDEBAR?.isOpen ?? false);
  // ... more state selectors for other UI elements

  // useMemo: Only recalculate the context object when dependencies change
  // This prevents creating a new object on every render, which improves performance
  const context = useMemo<AppContext>(() => ({
    IS_MODAL_OPEN: isModalOpen,      // Map state to context flag
    IS_SIDEBAR_OPEN: isSidebarOpen,  // Map state to context flag
    // ... map more state values to context flags
  }), [isModalOpen, isSidebarOpen]); // Only recalculate when these values change

  return { context }; // Return the context so other components can use it
};
Enter fullscreen mode Exit fullscreen mode

Why use useMemo? The useMemo hook ensures we only recompute the context when the actual state values change. Without it, we'd create a new context object on every render, even if nothing changed. This helps with performance and prevents unnecessary re-renders.

The Priority-Based Evaluation

Now for the fun part - the evaluation logic. When a key is pressed, we:

  1. Sort all keybindings by priority (lowest number first, like 1, 2, 3)
  2. Loop through them in order (check priority 1, then 2, then 3, etc.)
  3. Check if the condition matches the current context (should this shortcut be active right now?)
  4. Check if the key and modifiers match (is the right key being pressed?)
  5. Execute the first matching handler and stop (don't check any more shortcuts)

Here's the implementation with detailed comments:

// This function is called whenever a key is pressed
// It takes: the keyboard event, current app context, and available actions
// Returns: true if a shortcut was executed, false if nothing matched
export const executeKeybinding = (
  event: KeyboardEvent,    // The keyboard event (which key was pressed, etc.)
  context: AppContext,     // Current app state (what's open, what's closed)
  actions: Actions         // Available actions (closeModal, closeSidebar, etc.)
): boolean => {
  // Step 1: Sort all keybindings by priority
  // [...KEYBINDINGS] creates a copy of the array (so we don't modify the original)
  // .sort() arranges them by priority number
  // (a.priority ?? 999) means "use priority if it exists, otherwise use 999"
  // Lower numbers come first (priority 1 before priority 2)
  const sortedKeybindings = [...KEYBINDINGS].sort((a, b) => 
    (a.priority ?? 999) - (b.priority ?? 999)
  );

  // Step 2: Loop through sorted keybindings and find the first match
  for (const keybinding of sortedKeybindings) {
    // Step 3: Check if this shortcut's condition is met
    // The condition function returns true/false based on current context
    // Example: condition might check "is modal open?"
    if (!keybinding.condition(context)) {
      continue; // Skip this shortcut, check the next one
    }

    // Step 4: Check if the pressed key matches this shortcut's key
    // Also check if modifier keys (Ctrl, Shift, etc.) match
    if (!matchesKeybinding(event, keybinding)) {
      continue; // Keys don't match, skip to next shortcut
    }

    // Step 5: We found a match! Execute the handler

    // If preventDefault is true, stop the browser's default behavior
    // (e.g., prevent Escape from closing the browser tab)
    if (keybinding.preventDefault) {
      event.preventDefault();
    }

    // Call the handler function with the event, context, and actions
    // This is where the actual work happens (closing panels, etc.)
    keybinding.handler(event, context, actions);

    // Return true to indicate we handled the keypress
    // Don't check any more shortcuts - first match wins!
    return true;
  }

  // No shortcuts matched, return false
  return false;
};
Enter fullscreen mode Exit fullscreen mode

The matchesKeybinding function checks if the pressed key matches what the shortcut expects. I also added special handling for cross-platform shortcuts (Cmd on Mac, Ctrl on Windows/Linux):

// This function checks if a keyboard event matches a keybinding definition
// Returns true if they match, false otherwise
const matchesKeybinding = (event: KeyboardEvent, keybinding: Keybinding): boolean => {
  // Step 1: Check if the main key matches
  // event.key is the key that was pressed (e.g., "Escape", "k", "Enter")
  // keybinding.key is what the shortcut expects
  if (event.key !== keybinding.key) {
    return false; // Keys don't match, this shortcut doesn't apply
  }

  // Step 2: Check modifier keys (Ctrl, Shift, Alt, Cmd) if they're specified
  // Modifiers are optional - some shortcuts don't need them
  if (keybinding.modifiers) {
    // Extract the modifier requirements from the keybinding
    const { meta, ctrl, shift, alt } = keybinding.modifiers;

    // Special case: Cross-platform shortcuts
    // If both meta (Cmd) and ctrl are set to true, we use OR logic
    // This means: "Cmd OR Ctrl" - works on both Mac (Cmd) and Windows/Linux (Ctrl)
    // Example: Cmd+K on Mac OR Ctrl+K on Windows should both work
    if (meta === true && ctrl === true) {
      // Check if EITHER Cmd (Mac) OR Ctrl (Windows/Linux) is pressed
      if (!event.metaKey && !event.ctrlKey) {
        return false; // Neither modifier is pressed, doesn't match
      }
    } else {
      // Standard AND logic for other modifier combinations
      // If meta is specified, check if it matches what was pressed
      // Boolean() converts to true/false, !== checks for exact match
      if (meta !== undefined && Boolean(event.metaKey) !== meta) return false;
      if (ctrl !== undefined && Boolean(event.ctrlKey) !== ctrl) return false;
    }

    // Other modifiers (Shift, Alt) always use AND logic
    // They must match exactly what's specified
    if (shift !== undefined && Boolean(event.shiftKey) !== shift) return false;
    if (alt !== undefined && Boolean(event.altKey) !== alt) return false;
  }

  // All checks passed! The key and modifiers match
  return true;
};
Enter fullscreen mode Exit fullscreen mode

Understanding the cross-platform logic: On Mac, the Command key (⌘) is called metaKey. On Windows/Linux, it's ctrlKey. When we set both meta: true and ctrl: true, we're saying "accept either one" - making the shortcut work on all platforms.

Using Refs to Prevent Stale Closures

Alright, here's where we need to get a bit clever. This section might seem complex at first, but stick with me, I'll explain it in a way that makes sense.

The Challenge We're Solving

When you create an event listener in React, you want it to always know the current state of your app. But React has a quirk: functions created inside components "remember" the values they had when they were first created.

Let me show you what happens if we do this the naive way:

// ❌ This looks simple, but it has problems!
const handleKeyDown = useCallback((event: KeyboardEvent) => {
  executeKeybinding(event, context, actions);
}, [context, actions]); // ← Including these causes trouble
Enter fullscreen mode Exit fullscreen mode

The problem: Every time context or actions change (which happens whenever your app state changes), React creates a brand new handleKeyDown function. This triggers our useEffect to remove the old event listener and add a new one.

Imagine you're listening to a radio, but every time the song changes, someone unplugs your radio and plugs it back in. That's what's happening here, constant re-subscription is inefficient and can cause performance issues.

What's a "Stale Closure"?

A closure is JavaScript's way of letting functions "remember" variables from their surroundings. It's like a function taking a snapshot of the world around it when it's created.

A stale closure happens when that snapshot is outdated. Your function thinks the modal is closed because that's what it saw when it was created, but actually the modal opened five minutes ago.

Here's a simple example:

// Component renders, modal is closed
const isModalOpen = false;

// We create a handler that "remembers" isModalOpen = false
const handleKeyDown = () => {
  if (isModalOpen) { // This will ALWAYS be false, even after modal opens!
    closeModal();
  }
};

// Later, modal opens, component re-renders
// But handleKeyDown still thinks isModalOpen is false!
Enter fullscreen mode Exit fullscreen mode

This is why we need refs, they let us always access the latest values without recreating our functions.

The Solution: Refs (React's Secret Weapon)

Refs are React's way of storing values that survive re-renders without causing them. Think of a ref like a shared notebook that everyone can read and update, but updating it doesn't cause a commotion (no re-renders).

The key insight: We keep the handler function the same (so the listener stays attached), but we always read the latest values from refs.

Step-by-Step: Building Our Ref-Based Solution

Let's build this piece by piece. I'll explain each part and why it matters.

Step 1: Create a ref to store our context

First, we create a "box" (ref) to store the current context. This box persists across renders, and we can update it without causing re-renders.

// Create a ref - think of it as a box that holds our context
const contextRef = useRef(context);

// Whenever context changes, update the box
// This happens silently - no re-renders, no listener re-subscription
useEffect(() => {
  contextRef.current = context; // Put the latest context in the box
}, [context]);
Enter fullscreen mode Exit fullscreen mode

Step 2: Create a ref to store our actions

Same idea for our action functions. We store them in a ref so they don't cause our handler to be recreated.

// Create another box for our action functions
const actionsRef = useRef<Actions>({
  closeModal: selectors.modal.close,
  closeSidebar: selectors.sidebar.close,
  // ... more actions
});

// Update the box whenever action functions change
useEffect(() => {
  actionsRef.current = {
    closeModal: selectors.modal.close,
    closeSidebar: selectors.sidebar.close,
    // ... update more actions
  };
}, [
  selectors.modal.close,
  selectors.sidebar.close,
  // ... more dependencies
]);
Enter fullscreen mode Exit fullscreen mode

Step 3: Create the handler function (the magic happens here)

This is where the magic happens. We create the handler function once, with an empty dependency array. This means React creates it once and never recreates it.

// Create the handler function ONCE and never recreate it
// Empty array [] = "create this once, never change it"
const handleKeyDown = useCallback((event: KeyboardEvent) => {
  // When a key is pressed, read from our refs
  // .current gets the latest value from the ref
  // Even though handleKeyDown was created once, it always reads fresh data!
  executeKeybinding(event, contextRef.current, actionsRef.current);
}, []); // ← Empty array = stable function reference
Enter fullscreen mode Exit fullscreen mode

Step 4: Register the event listener

Finally, we attach our handler to the document. Because handleKeyDown never changes (empty dependency array), this effect only runs once.

// Set up the event listener
useEffect(() => {
  // Add listener when component mounts
  document.addEventListener('keydown', handleKeyDown);

  // Remove listener when component unmounts (cleanup)
  return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]); // Only re-run if handleKeyDown changes (it won't!)
Enter fullscreen mode Exit fullscreen mode

The Complete Picture

Here's the full implementation all together:

export const useAppShortcutHandler = (context: AppContext) => {
  const selectors = useAppSelectors();

  // Step 1: Store context in a ref
  const contextRef = useRef(context);
  useEffect(() => {
    contextRef.current = context;
  }, [context]);

  // Step 2: Store actions in a ref
  const actionsRef = useRef<Actions>({
    closeModal: selectors.modal.close,
    closeSidebar: selectors.sidebar.close,
    // ... more actions
  });

  useEffect(() => {
    actionsRef.current = {
      closeModal: selectors.modal.close,
      closeSidebar: selectors.sidebar.close,
      // ... update more actions
    };
  }, [
    selectors.modal.close,
    selectors.sidebar.close,
    // ... more dependencies
  ]);

  // Step 3: Create handler that reads from refs
  const handleKeyDown = useCallback((event: KeyboardEvent) => {
    executeKeybinding(event, contextRef.current, actionsRef.current);
  }, []); // Empty deps = create once, never recreate

  // Step 4: Register listener (only once!)
  useEffect(() => {
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [handleKeyDown]);
};
Enter fullscreen mode Exit fullscreen mode

Why This Works So Well

This pattern gives us three huge benefits:

  1. The listener is registered once - Because handleKeyDown never changes (empty dependency array), the listener is added once and stays attached. No constant removal and re-addition.

  2. Always fresh data - Even though handleKeyDown was created once, it always reads the latest values from contextRef.current and actionsRef.current. It's like having a window that always shows the current weather, even though the window itself never changes.

  3. No infinite loops - Refs update silently without triggering re-renders or function recreations. Your component stays stable.

A Real-World Analogy

Imagine you're a security guard at a building. You have a clipboard (the handler function) that you fill out once. But instead of writing the current information on the clipboard, you write "check the whiteboard" (the ref).

  • The clipboard never changes (stable handler function)
  • The whiteboard gets updated with the latest information (ref updates)
  • Every time you need information, you check the whiteboard (reading from .current)

This way, you always have the latest information without needing a new clipboard every time something changes!

A Real-World Example: Escape Key Hierarchy

Now let's see this system in action! I'll show you how I handled the Escape key in my application.

The Scenario

I had a complex UI with multiple layers:

  • A modal dialog
  • A sidebar
  • Primary panels
  • Secondary panels
  • Nested panels (panels within panels)

When a user pressed Escape, I wanted it to close things in a logical order, like peeling an onion, from the inside out. The most nested thing should close first, then work outward.

The Priority System

I organized my Escape key handlers by priority. Lower numbers run first:

  1. Priority 1: Most nested panel - The deepest panel closes first
  2. Priority 2: Secondary panel - Next level up
  3. Priority 3: Primary panel - The main panel
  4. Priority 4: Sidebar - Only if no panels are open
  5. Priority 5: Modal - Only if nothing else is open

Think of it like a stack of plates. When you press Escape, you remove the top plate first, then the next one, and so on.

The Implementation

Here's how I defined these shortcuts in my registry:

export const KEYBINDINGS: Keybinding[] = [
  // Priority 1: Close nested panel first (highest priority)
  {
    key: 'Escape',
    condition: (ctx) => ctx.IS_NESTED_PANEL_OPEN, // Only if nested panel is open
    handler: (event, ctx, actions) => {
      actions.closeNestedPanel?.(); // Close it!
    },
    preventDefault: true,
    priority: 1, // Check this first
  },

  // Priority 2: Close secondary panel
  {
    key: 'Escape',
    condition: (ctx) => ctx.IS_SECONDARY_PANEL_OPEN,
    handler: (event, ctx, actions) => {
      actions.closeSecondaryPanel?.();
    },
    preventDefault: true,
    priority: 2,
  },

  // Priority 3: Close primary panel
  {
    key: 'Escape',
    condition: (ctx) => ctx.IS_PRIMARY_PANEL_OPEN,
    handler: (event, ctx, actions) => {
      actions.closePrimaryPanel?.();
    },
    preventDefault: true,
    priority: 3,
  },

  // Priority 4: Close sidebar (only if no panels are open)
  {
    key: 'Escape',
    // This condition checks: sidebar open AND all panels closed
    condition: (ctx) => ctx.IS_SIDEBAR_OPEN && 
                       !ctx.IS_PRIMARY_PANEL_OPEN &&
                       !ctx.IS_SECONDARY_PANEL_OPEN &&
                       !ctx.IS_NESTED_PANEL_OPEN,
    handler: (event, ctx, actions) => {
      actions.closeSidebar?.();
    },
    preventDefault: true,
    priority: 4,
  },

  // Priority 5: Close modal (only if nothing else is open)
  {
    key: 'Escape',
    // Most restrictive: modal open AND everything else closed
    condition: (ctx) => ctx.IS_MODAL_OPEN && 
                       !ctx.IS_SIDEBAR_OPEN &&
                       !ctx.IS_PRIMARY_PANEL_OPEN &&
                       !ctx.IS_SECONDARY_PANEL_OPEN &&
                       !ctx.IS_NESTED_PANEL_OPEN,
    handler: (event, ctx, actions) => {
      actions.closeModal?.();
    },
    preventDefault: true,
    priority: 5, // Check this last
  },
];
Enter fullscreen mode Exit fullscreen mode

How It Works: A Walkthrough

Let's trace through what happens when a user presses Escape in different scenarios:

Scenario 1: Nested panel is open

  1. System checks Priority 1 → IS_NESTED_PANEL_OPEN is true
  2. Condition matches! → Close nested panel
  3. Stop checking (first match wins)

Scenario 2: Only sidebar and modal are open

  1. System checks Priority 1 → Nested panel? No ❌
  2. System checks Priority 2 → Secondary panel? No ❌
  3. System checks Priority 3 → Primary panel? No ❌
  4. System checks Priority 4 → Sidebar open AND no panels? Yes ✅
  5. Condition matches! → Close sidebar
  6. Stop checking

Scenario 3: Only modal is open

  1. System checks Priorities 1-4 → All fail ❌
  2. System checks Priority 5 → Modal open AND everything closed? Yes ✅
  3. Condition matches! → Close modal

The Key Insight

Notice how the lower priority handlers (sidebar and modal) explicitly check that higher priority panels aren't open. This ensures the correct order, panels always close before sidebar, and sidebar closes before modal.

The result? Predictable, intuitive behavior. Users press Escape, and the most nested thing closes first, just like they expect. It's like closing windows on your computer, the topmost window closes first.

Best Practices: Lessons Learned

After using this system in production, here are the key lessons I learned:

1. Use Sequential Priority Numbers

Don't skip numbers! Use 1, 2, 3, 4, 5 instead of 1, 5, 10, 20. Why? Because you'll inevitably need to insert a new priority between existing ones. If you've used 1, 5, 10, where do you put priority 3? You end up with decimals or awkward numbering.

Good: priority: 1, 2, 3, 4, 5

Bad: priority: 1, 5, 10, 20

2. Document Your Priority Order

Add comments explaining why each priority is set the way it is. Six months from now, when you're wondering why the sidebar closes before the modal, you'll thank yourself.

// Priority 4: Sidebar closes before modal because users expect
// panels to close first, then sidebar, then modal
{
  priority: 4,
  // ...
}
Enter fullscreen mode Exit fullscreen mode

3. Keep Conditions Explicit and Clear

If your condition is complex, break it down or add comments. Future you (and your teammates) will appreciate it.

// Good: Clear and explicit
condition: (ctx) => ctx.IS_MODAL_OPEN && 
                   !ctx.IS_SIDEBAR_OPEN &&
                   !ctx.IS_PANEL_OPEN

// Better: With explanation
condition: (ctx) => {
  // Modal must be open, and nothing else should be open
  return ctx.IS_MODAL_OPEN && 
         !ctx.IS_SIDEBAR_OPEN &&
         !ctx.IS_PANEL_OPEN;
}
Enter fullscreen mode Exit fullscreen mode

4. Always Prevent Default for Custom Shortcuts

If you're handling a key that the browser also uses (like Escape, Ctrl+S, etc.), always set preventDefault: true. Otherwise, both your handler and the browser's default behavior might run, causing unexpected results.

5. Use TypeScript

The type safety catches errors early. When you mistype a context property or forget a required field, TypeScript will tell you immediately. It's worth the setup.

6. Test Your Priority Order Thoroughly

When you add a new shortcut, test it with all relevant UI state combinations. Open a modal and a panel, then press Escape. Does it close the right thing? Test edge cases, you'll find bugs you didn't expect.

Common Mistakes I Made (So You Don't Have To)

I made my fair share of mistakes while building this system. Here are the big ones, and how to avoid them:

Mistake 1: Forgetting to Use Refs

The Problem: I initially included context and actions directly in my useCallback dependency array. This caused the event listener to be removed and re-added constantly, killing performance.

Why it happens: Every time your app state changes, React creates a new context object. Even if the values are the same, it's a new object reference. useCallback sees this as a change and recreates the handler, which triggers the useEffect to re-subscribe the listener.

The Fix: Use refs! Store context and actions in refs, and read from them in your handler. The handler function stays stable, but always reads fresh data.

Mistake 2: Action Functions Changing Every Render

The Problem: If your action functions are recreated on every render (common with arrow functions or functions that depend on state), and you use them in dependencies, you'll get infinite loops.

Why it happens:

// This creates a new function on every render
const closeModal = () => { /* ... */ };

// If you use it in dependencies:
useCallback(() => { /* ... */ }, [closeModal]); // ❌ Infinite loop!
Enter fullscreen mode Exit fullscreen mode

The Fix: Store actions in refs, just like context. Update the ref when actions change, but don't include them in the handler's dependencies.

Mistake 3: Conditions That Are Too Broad

The Problem: I had a shortcut with a condition like ctx.IS_MODAL_OPEN. It would match even when a panel was open, causing the wrong thing to close.

Why it happens: You forget to check that higher priority items aren't open. The system finds a match and stops checking, even though a higher priority item should have handled it.

The Fix: Always check that higher priority items are closed:

// ❌ Too broad
condition: (ctx) => ctx.IS_MODAL_OPEN

// ✅ Specific enough
condition: (ctx) => ctx.IS_MODAL_OPEN && 
                   !ctx.IS_PANEL_OPEN &&
                   !ctx.IS_SIDEBAR_OPEN
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Forgetting preventDefault

The Problem: I handled Escape key presses, but forgot to prevent the browser's default behavior. Sometimes my handler ran, sometimes the browser's default ran, sometimes both, chaos!

Why it happens: Browsers have default behaviors for many keys. Escape closes dialogs, Ctrl+S saves pages, etc. If you don't prevent these, you get unpredictable behavior.

The Fix: Always set preventDefault: true for keys you're handling:

{
  key: 'Escape',
  preventDefault: true, // ← Don't forget this!
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Mistake 5: Wrong Priority Order

The Problem: I put modal closing at priority 1 and panel closing at priority 2. When both were open, the modal would close first, which felt wrong to users.

Why it happens: You think about what's "most important" rather than what should close first. Users expect the topmost/most nested thing to close first.

The Fix: Think about the user's mental model. What's on top? What's most nested? That should close first. Test with all combinations of UI states to verify the order feels right.

Quick Reference: Key Terms

Here's a handy glossary of terms we've used:

Term What It Means
Registry The central list/array where all keyboard shortcuts are stored
Keybinding A single keyboard shortcut definition (key + modifiers + condition + handler)
Context An object representing your app's current state (what's open, what's closed)
Condition A function that returns true/false to determine if a shortcut should be active
Handler The function that runs when a shortcut is triggered
Priority A number that determines execution order (lower = runs first)
Ref React's way to store values without triggering re-renders
Stale Closure When a function remembers old values instead of current ones
useMemo React hook that only recalculates when dependencies change
useCallback React hook that memoizes (remembers) a function
useEffect React hook that runs side effects (like adding event listeners)
useRef React hook that creates a ref to store values
Modifiers Special keys like Ctrl, Shift, Alt, Cmd that modify the main key
preventDefault Stops the browser's default behavior for a key

Wrapping Up

Building this centralized keyboard shortcut system completely changed how I handle keyboard events in React. What started as a frustrating problem with conflicting handlers became a clean, maintainable solution.

What We Built

We created a system that:

  • ✅ Centralizes all shortcuts in one place (easy to find and maintain)
  • ✅ Uses priority ordering for predictable behavior (always know what runs first)
  • ✅ Uses context-driven conditions (shortcuts only activate when they should)
  • ✅ Leverages refs to avoid performance issues (no constant re-subscriptions)
  • ✅ Provides type safety with TypeScript (catch errors early)

The Big Picture

The core idea is simple: one registry, priority-based execution, context-aware conditions, and refs for performance. Once you understand these concepts, the whole system clicks into place.

Is This Right for You?

If you're dealing with:

  • Multiple keyboard shortcuts that might conflict
  • Complex UI with nested panels, modals, and overlays
  • Need for predictable shortcut behavior
  • Performance concerns with event listeners

Then yes, this approach is worth the initial setup. It might seem like overkill for simple apps, but once you have it, adding new shortcuts is trivial, and debugging is much easier.

Next Steps

  1. Start with a simple registry for your most important shortcuts
  2. Add priorities as you discover conflicts
  3. Build your context system based on your app's state
  4. Use refs from the start (trust me, you'll need them)

Final Thoughts

The best part? Once this system is in place, adding a new keyboard shortcut is just adding an object to an array. No more hunting through components, no more conflicts, no more "why isn't this working?" moments.

Have you built something similar? I'd love to hear about your approach, or if you have questions, drop them in the comments below!

Top comments (0)