useDeepCompareEffect
useEffect, but uses deep comparison on its dependencies instead of reference equality.Installation
npx sse-hooks add use-deep-compare-effect
yarn dlx sse-hooks add use-deep-compare-effect
pnpm dlx sse-hooks add use-deep-compare-effect
deno run -A npm:sse-hooks add use-deep-compare-effect
bunx sse-hooks add use-deep-compare-effect
Usage
API
Parameters
| Parameter | Default | Type |
|---|---|---|
options |
|
|
keyMap | - |
|
Returns
| Return Value | Default | Type |
|---|---|---|
bindings | - |
|
Types Aliases
KeyMap
typetype KeyMap = Record<string, KeyHandler | KeyConfig>;
KeyHandler
typetype KeyHandler = (event: React.KeyboardEvent) => void;
KeyConfig
interface| Property | Type |
|---|---|
action |
The function to call |
description? |
Description for UI (e.g. "Save File") |
category? |
Category for UI (e.g. "Navigation") |
preventDefault? |
Prevent default browser behavior |
stopPropagation? |
Stop event propagation |
once? |
Only fire this hotkey once, then disable it |
allowInInputs? |
Allow this hotkey inside inputs/textareas |
Code
import { useEffect, useRef, useState, KeyboardEvent, useMemo } from "react";
import React from "react";
import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect";
export type KeyHandler = (event: React.KeyboardEvent) => void;
export type KeyConfig = {
/** The function to call */
action: KeyHandler;
/** Description for UI (e.g. "Save File") */
description?: string;
/** Category for UI (e.g. "Navigation") */
category?: string;
/** Prevent default browser behavior */
preventDefault?: boolean;
/** Stop event propagation */
stopPropagation?: boolean;
/** Only fire this hotkey once, then disable it */
once?: boolean;
/** Allow this hotkey inside inputs/textareas */
allowInInputs?: boolean;
};
export type KeyMap = Record<string, KeyHandler | KeyConfig>;
export type UseKeyOptions = {
/** DOM target to listen on (default: window) */
target?: React.RefObject<HTMLElement> | null;
/** Event type (default: keydown) */
event?: "keydown" | "keyup";
/** Global toggle to disable all hooks */
enabled?: boolean;
/** If true, logs key presses to console */
debug?: boolean;
/** Time in ms to wait for a sequence (e.g. "g g") */
sequenceTimeout?: number;
/** Custom filter: return false to skip the event */
filter?: (e: React.KeyboardEvent) => boolean;
};
export const KEY_ALIASES: Record<string, string> = {
ctrl: "control",
opt: "alt",
option: "alt",
cmd: "meta",
command: "meta",
esc: "escape",
space: " ",
" ": "space",
};
/**
* Normalizes a key event or string into a standard format:
* "ctrl+alt+shift+key" (modifiers sorted alphabetically).
*/
export function normalizeKeyCombo(e: React.KeyboardEvent | string): string {
if (typeof e === "string") {
// Handle sequences like "g g" -> recursive normalization is tricky,
// so we assume the user config passes individual chords like "ctrl+k"
const parts = e
.toLowerCase()
.split("+")
.map((p) => p.trim());
const key = parts.pop();
const mods = parts.map((m) => KEY_ALIASES[m] || m).sort();
return [...mods, key === " " ? "space" : key].join("+");
}
const mods = [];
if (e.ctrlKey) mods.push("control");
if (e.altKey) mods.push("alt");
if (e.metaKey) mods.push("meta");
if (e.shiftKey) mods.push("shift");
mods.sort();
let key = e.key.toLowerCase();
if (key === " ") key = "space";
if (["control", "alt", "meta", "shift"].includes(key)) {
return key;
}
return [...mods, key].join("+");
}
export function isInputTarget(e: React.KeyboardEvent): boolean {
const target = e.target as HTMLElement;
return (
target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
);
}
/**
* CORE HOOK: useKeyListener
* Low-level hook to bind a single event listener with lifecycle management.
*/
export function useKeyListener(
handler: (e: KeyboardEvent) => void,
options: UseKeyOptions = {},
) {
const { target, event = "keydown", enabled = true } = options;
const handlerRef = useRef(handler);
useIsomorphicLayoutEffect(() => {
handlerRef.current = handler;
}, []);
useEffect(() => {
if (!enabled) return;
const node = target ? target.current : window;
if (!node) return;
const eventListener = (e: Event) =>
handlerRef.current(e as unknown as KeyboardEvent);
node.addEventListener(event, eventListener);
return () => node.removeEventListener(event, eventListener);
}, [target, event, enabled]);
}
/**
* A powerful sensor hook for handling keyboard shortcuts, sequences, and modifiers.
*
* It supports complex key combinations (`Ctrl+Shift+S`), Gmail-style sequences (`g then i`),
* and provides metadata for generating "Keyboard Shortcut" UI help modals.
*
* @category sensors
* @param {KeyMap} keyMap - An object defining the key bindings and their actions.
* @param {UseKeyOptions} [options] - Global configuration options.
* @returns {{ bindings: Array<{ keys: string, category: string, description: string }> }} - Metadata about the registered bindings for UI display.
*
* @throws Will log a warning in debug mode if a key combination is invalid.
* @see [Documentation](https://sse-hooks.vercel.app/docs/hooks/use-key)
* @public
*/
export function useKey(keyMap: KeyMap, options: UseKeyOptions = {}) {
const { debug = false, sequenceTimeout = 1000, filter } = options;
const [buffer, setBuffer] = useState<string[]>([]);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const firedRef = useRef<Set<string>>(new Set());
const callbackMapRef = useRef(keyMap);
useIsomorphicLayoutEffect(() => {
callbackMapRef.current = keyMap;
});
const handleEvent = (e: React.KeyboardEvent) => {
// 1. Normalize
const currentCombo = normalizeKeyCombo(e);
if (debug) {
console.log(
`[useKey] Pressed: ${e.key} | Normalized: ${currentCombo} | Buffer: ${buffer.join(" ")}`,
);
}
if (filter && !filter(e)) return;
const nextBuffer = [...buffer, currentCombo];
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setBuffer([]), sequenceTimeout);
setBuffer(nextBuffer);
const sequenceKey = nextBuffer.join(" ");
Object.entries(callbackMapRef.current).forEach(([keyBinding, value]) => {
// Normalize user's binding (e.g. "Ctrl+Shift+S" -> "control+shift+s")
const normalizedBinding = keyBinding
.split(" ")
.map(normalizeKeyCombo)
.join(" ");
const config: KeyConfig =
typeof value === "function" ? { action: value } : value;
let isMatch = false;
if (normalizedBinding === sequenceKey) {
isMatch = true;
setBuffer([]);
} else if (
normalizedBinding === currentCombo &&
nextBuffer.length === 1
) {
isMatch = true;
}
if (isMatch) {
// --- Checks ---
if (config.once && firedRef.current.has(normalizedBinding)) return;
if (!config.allowInInputs && isInputTarget(e)) return;
// --- Execute ---
if (config.preventDefault) e.preventDefault();
if (config.stopPropagation) e.stopPropagation();
if (config.once) firedRef.current.add(normalizedBinding);
if (debug) console.log(`[useKey] Triggered: ${keyBinding}`);
config.action(e);
}
});
};
useKeyListener(handleEvent, options);
const bindings = useMemo(() => {
return Object.entries(keyMap).map(([keys, value]) => {
const config = typeof value === "function" ? ({} as KeyConfig) : value;
return {
keys,
category: config.category || "General",
description: config.description || "No description",
};
});
}, [keyMap]);
return { bindings };
}
import { useEffect, useRef, useState, useMemo } from "react";
import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect";
export const KEY_ALIASES = {
ctrl: "control",
opt: "alt",
option: "alt",
cmd: "meta",
command: "meta",
esc: "escape",
space: " ",
" ": "space",
};
export function normalizeKeyCombo(e) {
if (typeof e === "string") {
const parts = e
.toLowerCase()
.split("+")
.map((p) => p.trim());
const key = parts.pop();
const mods = parts.map((m) => KEY_ALIASES[m] || m).sort();
return [...mods, key === " " ? "space" : key].join("+");
}
const mods = [];
if (e.ctrlKey) mods.push("control");
if (e.altKey) mods.push("alt");
if (e.metaKey) mods.push("meta");
if (e.shiftKey) mods.push("shift");
mods.sort();
let key = e.key.toLowerCase();
if (key === " ") key = "space";
if (["control", "alt", "meta", "shift"].includes(key)) {
return key;
}
return [...mods, key].join("+");
}
export function isInputTarget(e) {
const target = e.target;
return (
target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
);
}
export function useKeyListener(handler, options = {}) {
const { target, event = "keydown", enabled = true } = options;
const handlerRef = useRef(handler);
useIsomorphicLayoutEffect(() => {
handlerRef.current = handler;
}, []);
useEffect(() => {
if (!enabled) return;
const node = target ? target.current : window;
if (!node) return;
const eventListener = (e) => handlerRef.current(e);
node.addEventListener(event, eventListener);
return () => node.removeEventListener(event, eventListener);
}, [target, event, enabled]);
}
export function useKey(keyMap, options = {}) {
const { debug = false, sequenceTimeout = 1000, filter } = options;
const [buffer, setBuffer] = useState([]);
const timeoutRef = useRef(null);
const firedRef = useRef(new Set());
const callbackMapRef = useRef(keyMap);
useIsomorphicLayoutEffect(() => {
callbackMapRef.current = keyMap;
});
const handleEvent = (e) => {
const currentCombo = normalizeKeyCombo(e);
if (debug) {
console.log(
`[useKey] Pressed: ${e.key} | Normalized: ${currentCombo} | Buffer: ${buffer.join(" ")}`,
);
}
if (filter && !filter(e)) return;
const nextBuffer = [...buffer, currentCombo];
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setBuffer([]), sequenceTimeout);
setBuffer(nextBuffer);
const sequenceKey = nextBuffer.join(" ");
Object.entries(callbackMapRef.current).forEach(([keyBinding, value]) => {
const normalizedBinding = keyBinding
.split(" ")
.map(normalizeKeyCombo)
.join(" ");
const config = typeof value === "function" ? { action: value } : value;
let isMatch = false;
if (normalizedBinding === sequenceKey) {
isMatch = true;
setBuffer([]);
} else if (
normalizedBinding === currentCombo &&
nextBuffer.length === 1
) {
isMatch = true;
}
if (isMatch) {
if (config.once && firedRef.current.has(normalizedBinding)) return;
if (!config.allowInInputs && isInputTarget(e)) return;
if (config.preventDefault) e.preventDefault();
if (config.stopPropagation) e.stopPropagation();
if (config.once) firedRef.current.add(normalizedBinding);
if (debug) console.log(`[useKey] Triggered: ${keyBinding}`);
config.action(e);
}
});
};
useKeyListener(handleEvent, options);
const bindings = useMemo(() => {
return Object.entries(keyMap).map(([keys, value]) => {
const config = typeof value === "function" ? {} : value;
return {
keys,
category: config.category || "General",
description: config.description || "No description",
};
});
}, [keyMap]);
return { bindings };
}