← ./articles

React Modal Shortcuts Still Fire? Use a Ref Guard

A modal is open, but pressing an arrow key moves selection behind it. Pressing Escape closes the dialog and also clears the underlying graph. Adding stopPropagation() to the modal does not fix the duplicate action.

The likely cause is a global listener registered in the capture phase. It runs before the modal's normal React onKeyDown handler, so the modal cannot undo work that already happened.

Confirm that the global listener uses capture phase

Look for either form:

document.addEventListener("keydown", handleKeyDown, true);
document.addEventListener("keydown", handleKeyDown, { capture: true });

The event order is capture from the document toward the target, then target handling, then bubbling back outward. A bubble-phase stopPropagation() only stops later bubble handlers.

This is different from the browser default action covered in Tauri Ctrl+S Needs preventDefault. Here, two application handlers are competing.

Mirror the modal state into a ref

The capture listener needs the current modal state without being reinstalled on every render. Keep UI rendering in state and mirror the value into a ref:

const [isHelpOpen, setIsHelpOpen] = useState(false);
const isHelpOpenRef = useRef(false);

useEffect(() => {
  isHelpOpenRef.current = isHelpOpen;
}, [isHelpOpen]);

For several overlays, pass one derived flag instead of adding scattered special cases:

const isOverlayOpen = isHelpOpen || isSearchOpen || isSettingsOpen;

useEffect(() => {
  isOverlayOpenRef.current = isOverlayOpen;
}, [isOverlayOpen]);

Return before any global shortcut mutates state

Check the ref at the first line of the capture handler:

useEffect(() => {
  const handleKeyDown = (event: KeyboardEvent) => {
    if (isOverlayOpenRef.current) {
      return;
    }

    if (event.key === "Escape") clearSelection();
    if (event.key === "ArrowRight") moveSelection(1);
  };

  document.addEventListener("keydown", handleKeyDown, true);
  return () => document.removeEventListener("keydown", handleKeyDown, true);
}, [clearSelection, moveSelection]);

Put the guard before preventDefault(), state writes, analytics, or shortcut dispatch. The purpose is to make the global layer behave as if it did not receive the key while an overlay owns keyboard interaction.

Let the modal own Escape and Tab

Keep modal behavior local:

<div
  role="dialog"
  aria-modal="true"
  onKeyDown={(event) => {
    if (event.key === "Escape") {
      event.stopPropagation();
      onClose();
    }
  }}
>
  {/* focusable modal content */}
</div>

The ref guard prevents the earlier capture action. The modal's handler prevents its Escape event from reaching unrelated bubble handlers. Focus placement and focus trapping remain separate modal responsibilities.

Test open and closed states

Verify behavior as a matrix:

| State | Key | Expected result | |---|---|---| | Modal closed | Arrow | Background selection moves | | Modal open | Arrow | Background remains unchanged | | Modal open | Escape | Modal closes once | | Modal open | Tab | Focus stays within the modal | | Modal closed | Escape | Normal global action runs |

In an automated test, assert call counts for clearSelection, moveSelection, and onClose. A visual check alone can miss a background state mutation.

References

Summary

When a global capture listener runs before a modal handler, bubble-phase stopPropagation() is too late. Mirror overlay state into a ref, return at the start of the capture handler, and let the modal exclusively handle its Escape, Tab, and focus behavior.