useHotkeys binds a keyboard shortcut — a chord like ctrl+k or cmd+shift+p — to a handler that runs only when exactly that combination is pressed. Command palettes, editors, and dashboards lean on shortcuts, and getting them right is subtler than a keydown listener looks: the hook has to match the modifier keys exactly, and it has to stay out of the way while the user is typing. You will implement it in the shape popularized by react-hotkeys-hook.
function useHotkeys(
keys: string | string[], // 'ctrl+k', 'cmd+shift+p', or ['ctrl+k', 'cmd+k']
handler: (event: KeyboardEvent) => void,
options?: {
enableOnFormTags?: boolean; // default false — do NOT fire while typing in a field
preventDefault?: boolean; // default false — call event.preventDefault() on a match
},
): void;
The hook returns nothing; it wires up a keydown listener as a side effect and cleans it up on unmount.
function App() {
const [open, setOpen] = useState(false);
// Open the command palette on Ctrl+K.
useHotkeys('ctrl+k', () => setOpen(true));
return open ? <Palette onClose={() => setOpen(false)} /> : null;
}
// useHotkeys('ctrl+k', handler)
// keydown ctrl+k -> handler fires
// keydown ctrl+shift+k -> ignored (an extra modifier is held)
// keydown k -> ignored (ctrl is required)
// keydown k (in an input)-> ignored (that keystroke is the user typing)
ctrl+k binding fires only on ctrl+k. Every required modifier must be present and every un-required modifier must be absent, so ctrl+shift+k does not trigger it.ctrl/control, shift, alt/option, and meta/cmd/command, joined with +. Decide whether to support a mod alias (Cmd on macOS, Ctrl elsewhere) and justify the platform detection it needs.input, textarea, select, or contentEditable element, or a bare-letter shortcut would eat the user's keystrokes. enableOnFormTags opts back in.keydown. This binds a chord to an action; it does not report whether a key is being held. For that held-key boolean, see useKeyPress.You will parse a combo string like ctrl+k into the exact set of modifier flags it demands, match that against every keydown, stay silent while the user is typing, and keep the listener subscribed once by reading the latest handler out of a ref.
A keyboard shortcut sounds like one line: listen for keydown, check event.key, call the handler. But event.key === 'k' fires on a bare k, on ctrl+k, and on ctrl+shift+k — three different chords, one handler. A real shortcut is a specific chord: these modifiers held, those ones not, this key. On top of that it has to stay out of the way — a bare-letter shortcut cannot steal a keystroke while someone types in a search box — and it has to survive the way React re-runs your component, handing the effect a brand-new handler function on every render.
Every keydown carries four modifier booleans — ctrlKey, metaKey, altKey, shiftKey — plus the key that was produced. A combo is a claim about all five. ctrl+k means ctrlKey is true, the other three are false, and key is k. Matching is exact: the required modifiers must be present and the un-required ones must be absent. That second half is the part everyone forgets, and it is the entire difference between a shortcut and a bug.
The naive check event.key === 'k' && event.ctrlKey only asks about the modifiers it wants. It never asks about shift, so ctrl+shift+k sails straight through and the handler fires when it should not. Checking all four flags against the combo is what closes the gap.
The obvious version splits the string, pulls off the main key, and checks the modifiers it recognizes:
const { useEffect } = require('react');
function useHotkeys(keys, handler) {
useEffect(() => {
const parts = keys.toLowerCase().split('+');
const wantKey = parts[parts.length - 1];
const wantCtrl = parts.includes('ctrl');
const onKeyDown = (event) => {
if (event.key.toLowerCase() !== wantKey) return;
if (wantCtrl && !event.ctrlKey) return;
handler(event); // fires!
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [keys]);
}
Three things are wrong. It never checks that the un-asked modifiers are absent, so ctrl+k also fires on ctrl+shift+k. It never looks at where focus is, so a bare-key shortcut fires while the user is typing. And it keys the effect on keys while calling the handler it captured on the first run — pass a fresh inline function each render and the shortcut quietly runs a stale one.
const { useEffect, useRef } = require('react');
// Modifier tokens and their aliases, each normalized to one of the four flags.
const MOD_ALIASES = {
ctrl: 'ctrl',
control: 'ctrl',
shift: 'shift',
alt: 'alt',
option: 'alt',
meta: 'meta',
cmd: 'meta',
command: 'meta',
win: 'meta',
super: 'meta',
};
// Bare keys that read more naturally by an alias than by their event.key value.
const KEY_ALIASES = { esc: 'escape', space: ' ', spacebar: ' ' };
// `mod` is the platform's primary shortcut modifier: Cmd on macOS, Ctrl elsewhere.
function isMac() {
return typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent || '');
}
// Turn 'ctrl+shift+k' into { ctrl, meta, alt, shift, key } — the exact shape a
// matching event must have. Un-named modifiers stay false; that half is the point.
function parseCombo(combo) {
const spec = { ctrl: false, meta: false, alt: false, shift: false, key: '' };
combo
.toLowerCase()
.split('+')
.map((token) => token.trim())
.filter(Boolean)
.forEach((token) => {
if (token === 'mod') {
spec[isMac() ? 'meta' : 'ctrl'] = true;
} else if (MOD_ALIASES[token]) {
spec[MOD_ALIASES[token]] = true;
} else {
spec.key = KEY_ALIASES[token] || token;
}
});
return spec;
}
// EXACT match: every one of the four modifier flags equals what the combo asks
// for (required ones present, un-required ones absent) and the key matches.
function eventMatches(event, spec) {
return (
event.ctrlKey === spec.ctrl &&
event.metaKey === spec.meta &&
event.altKey === spec.alt &&
event.shiftKey === spec.shift &&
event.key.toLowerCase() === spec.key
);
}
// Is focus somewhere that typing must win over shortcuts?
function isFormField(target) {
if (!target || !target.tagName) return false;
const tag = target.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
target.isContentEditable === true
);
}
function useHotkeys(keys, handler, options) {
// The handler is almost always a fresh inline function each render. Keep the
// LATEST one in a ref so the listener reads it at call time and never goes stale.
const handlerRef = useRef(handler);
handlerRef.current = handler;
// Read options through a ref too, so toggling preventDefault or enableOnFormTags
// does not force the listener to re-subscribe.
const optionsRef = useRef(options);
optionsRef.current = options;
// A stable string that changes ONLY when the bound combos change. The effect
// keys on this — not on `handler` — so the listener subscribes once.
const combos = Array.isArray(keys) ? keys : [keys];
const comboKey = combos.join(',');
useEffect(() => {
const specs = comboKey.split(',').map(parseCombo);
const onKeyDown = (event) => {
const opts = optionsRef.current || {};
// Don't hijack typing: bail before matching when focus is in a field,
// unless the caller opted in.
if (!opts.enableOnFormTags && isFormField(event.target)) return;
if (!specs.some((spec) => eventMatches(event, spec))) return;
if (opts.preventDefault) event.preventDefault();
handlerRef.current(event);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [comboKey]);
}
module.exports = { useHotkeys };
Three ideas carry this. parseCombo turns the string into a spec — one boolean per modifier plus the key — so the required and un-required modifiers are both written down. eventMatches compares all four flags against that spec, which is what makes ctrl+k reject ctrl+shift+k. And the handler never enters the dependency array: it rides in handlerRef, refreshed on every render, so the effect keys on comboKey alone and subscribes exactly once. An array of combos is just a list of specs — the listener fires if the event matches any of them.
enableOnFormTags defaults off, and that default is load-bearing. If a bare / opened your search palette even while the user was mid-word in a text box, they could never type a slash. So before matching anything, the handler asks what event.target is: an input, textarea, select, or a contentEditable element means the keystroke belongs to the user, and the shortcut steps aside. Setting enableOnFormTags: true opts back in for the rare shortcut that should fire everywhere.
The handler you pass is almost always written inline — useHotkeys('ctrl+k', () => setOpen(true)) — so it is a different function object on every render. If the effect depended on it, React would tear the keydown listener down and add a new one every single render: churn you do not want on a global listener. The other way out — subscribe once with an empty-ish dependency and call the handler you captured — freezes the handler at its first value, so it reads stale state forever.
The fix is the useLatest pattern: hold the newest handler in a ref, refreshed each render, and have the listener read handlerRef.current when a key is actually pressed. The effect then depends only on comboKey, a string that changes when the combo changes and at no other time — so one listener stays subscribed across every render while always running the current handler. It is the same trick useEventCallback wraps into a stable function.
Take a command palette: useHotkeys('ctrl+k', () => setOpen(true)).
handlerRef.current is set to the inline function. comboKey is 'ctrl+k'. The effect runs, parses one spec { ctrl: true, meta: false, alt: false, shift: false, key: 'k' }, and adds one keydown listener to document.ctrl+shift+k. The event has ctrlKey true, shiftKey true. eventMatches compares shiftKey === spec.shift, i.e. true === false — no match. The palette stays shut. The naive version, which never checked shift, would have opened it.ctrl+k. All four flags line up (ctrl true, the rest false) and key is k. It matches, so handlerRef.current(event) runs and the palette opens.k in a search box. event.target is an input, enableOnFormTags is off, so the handler bails before matching. The letter lands in the box.handlerRef.current is repointed; the effect does not re-run because comboKey is unchanged. The next ctrl+k runs the new handler — fresh, with no re-subscribe.event.ctrlKey && event.key === 'k' fires on ctrl+shift+k too. Compare all four modifier flags against the combo so the un-required ones must be absent.event.target check, a bare-key hotkey eats the user's keystrokes in inputs. Skip form fields by default; gate it behind enableOnFormTags.handler. An inline handler is a new function every render, so the listener re-subscribes constantly. Key on the combo string and read the handler from a ref.event.code instead of event.key. event.code is the physical key position (KeyK), not the character, so it ignores layout and case. Use event.key and lower-case it, as this hook does. (react-hotkeys-hook defaults to code and adds a useKey option for the reverse.)preventDefault. ctrl+s triggers the browser Save dialog and ctrl+p triggers Print unless you call event.preventDefault(). Pass preventDefault: true for shortcuts that shadow a browser default.g then i to go to your issues. That needs a short-lived buffer of recent keys and a timeout, not a single-event match. react-hotkeys-hook writes these with a > separator (g>i).document, so the shortcut is global. Returning a ref and binding to that element instead scopes the shortcut to a panel, which is how you give a modal its own Escape without the page behind it reacting.mod key, cross-platform. mod maps to Cmd on macOS and Ctrl everywhere else, so one binding covers ⌘K and Ctrl+K. This hook detects the platform from navigator.userAgent; it is untested here precisely because the result depends on the host OS.useKeyPress, a boolean that tracks the key across keydown and keyup rather than binding a chord to a handler.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useHotkeys binds a keyboard shortcut — a chord like ctrl+k or cmd+shift+p — to a handler that runs only when exactly that combination is pressed. Command palettes, editors, and dashboards lean on shortcuts, and getting them right is subtler than a keydown listener looks: the hook has to match the modifier keys exactly, and it has to stay out of the way while the user is typing. You will implement it in the shape popularized by react-hotkeys-hook.
function useHotkeys(
keys: string | string[], // 'ctrl+k', 'cmd+shift+p', or ['ctrl+k', 'cmd+k']
handler: (event: KeyboardEvent) => void,
options?: {
enableOnFormTags?: boolean; // default false — do NOT fire while typing in a field
preventDefault?: boolean; // default false — call event.preventDefault() on a match
},
): void;
The hook returns nothing; it wires up a keydown listener as a side effect and cleans it up on unmount.
function App() {
const [open, setOpen] = useState(false);
// Open the command palette on Ctrl+K.
useHotkeys('ctrl+k', () => setOpen(true));
return open ? <Palette onClose={() => setOpen(false)} /> : null;
}
// useHotkeys('ctrl+k', handler)
// keydown ctrl+k -> handler fires
// keydown ctrl+shift+k -> ignored (an extra modifier is held)
// keydown k -> ignored (ctrl is required)
// keydown k (in an input)-> ignored (that keystroke is the user typing)
ctrl+k binding fires only on ctrl+k. Every required modifier must be present and every un-required modifier must be absent, so ctrl+shift+k does not trigger it.ctrl/control, shift, alt/option, and meta/cmd/command, joined with +. Decide whether to support a mod alias (Cmd on macOS, Ctrl elsewhere) and justify the platform detection it needs.input, textarea, select, or contentEditable element, or a bare-letter shortcut would eat the user's keystrokes. enableOnFormTags opts back in.keydown. This binds a chord to an action; it does not report whether a key is being held. For that held-key boolean, see useKeyPress.You will parse a combo string like ctrl+k into the exact set of modifier flags it demands, match that against every keydown, stay silent while the user is typing, and keep the listener subscribed once by reading the latest handler out of a ref.
A keyboard shortcut sounds like one line: listen for keydown, check event.key, call the handler. But event.key === 'k' fires on a bare k, on ctrl+k, and on ctrl+shift+k — three different chords, one handler. A real shortcut is a specific chord: these modifiers held, those ones not, this key. On top of that it has to stay out of the way — a bare-letter shortcut cannot steal a keystroke while someone types in a search box — and it has to survive the way React re-runs your component, handing the effect a brand-new handler function on every render.
Every keydown carries four modifier booleans — ctrlKey, metaKey, altKey, shiftKey — plus the key that was produced. A combo is a claim about all five. ctrl+k means ctrlKey is true, the other three are false, and key is k. Matching is exact: the required modifiers must be present and the un-required ones must be absent. That second half is the part everyone forgets, and it is the entire difference between a shortcut and a bug.
The naive check event.key === 'k' && event.ctrlKey only asks about the modifiers it wants. It never asks about shift, so ctrl+shift+k sails straight through and the handler fires when it should not. Checking all four flags against the combo is what closes the gap.
The obvious version splits the string, pulls off the main key, and checks the modifiers it recognizes:
const { useEffect } = require('react');
function useHotkeys(keys, handler) {
useEffect(() => {
const parts = keys.toLowerCase().split('+');
const wantKey = parts[parts.length - 1];
const wantCtrl = parts.includes('ctrl');
const onKeyDown = (event) => {
if (event.key.toLowerCase() !== wantKey) return;
if (wantCtrl && !event.ctrlKey) return;
handler(event); // fires!
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [keys]);
}
Three things are wrong. It never checks that the un-asked modifiers are absent, so ctrl+k also fires on ctrl+shift+k. It never looks at where focus is, so a bare-key shortcut fires while the user is typing. And it keys the effect on keys while calling the handler it captured on the first run — pass a fresh inline function each render and the shortcut quietly runs a stale one.
const { useEffect, useRef } = require('react');
// Modifier tokens and their aliases, each normalized to one of the four flags.
const MOD_ALIASES = {
ctrl: 'ctrl',
control: 'ctrl',
shift: 'shift',
alt: 'alt',
option: 'alt',
meta: 'meta',
cmd: 'meta',
command: 'meta',
win: 'meta',
super: 'meta',
};
// Bare keys that read more naturally by an alias than by their event.key value.
const KEY_ALIASES = { esc: 'escape', space: ' ', spacebar: ' ' };
// `mod` is the platform's primary shortcut modifier: Cmd on macOS, Ctrl elsewhere.
function isMac() {
return typeof navigator !== 'undefined' && /mac/i.test(navigator.userAgent || '');
}
// Turn 'ctrl+shift+k' into { ctrl, meta, alt, shift, key } — the exact shape a
// matching event must have. Un-named modifiers stay false; that half is the point.
function parseCombo(combo) {
const spec = { ctrl: false, meta: false, alt: false, shift: false, key: '' };
combo
.toLowerCase()
.split('+')
.map((token) => token.trim())
.filter(Boolean)
.forEach((token) => {
if (token === 'mod') {
spec[isMac() ? 'meta' : 'ctrl'] = true;
} else if (MOD_ALIASES[token]) {
spec[MOD_ALIASES[token]] = true;
} else {
spec.key = KEY_ALIASES[token] || token;
}
});
return spec;
}
// EXACT match: every one of the four modifier flags equals what the combo asks
// for (required ones present, un-required ones absent) and the key matches.
function eventMatches(event, spec) {
return (
event.ctrlKey === spec.ctrl &&
event.metaKey === spec.meta &&
event.altKey === spec.alt &&
event.shiftKey === spec.shift &&
event.key.toLowerCase() === spec.key
);
}
// Is focus somewhere that typing must win over shortcuts?
function isFormField(target) {
if (!target || !target.tagName) return false;
const tag = target.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
target.isContentEditable === true
);
}
function useHotkeys(keys, handler, options) {
// The handler is almost always a fresh inline function each render. Keep the
// LATEST one in a ref so the listener reads it at call time and never goes stale.
const handlerRef = useRef(handler);
handlerRef.current = handler;
// Read options through a ref too, so toggling preventDefault or enableOnFormTags
// does not force the listener to re-subscribe.
const optionsRef = useRef(options);
optionsRef.current = options;
// A stable string that changes ONLY when the bound combos change. The effect
// keys on this — not on `handler` — so the listener subscribes once.
const combos = Array.isArray(keys) ? keys : [keys];
const comboKey = combos.join(',');
useEffect(() => {
const specs = comboKey.split(',').map(parseCombo);
const onKeyDown = (event) => {
const opts = optionsRef.current || {};
// Don't hijack typing: bail before matching when focus is in a field,
// unless the caller opted in.
if (!opts.enableOnFormTags && isFormField(event.target)) return;
if (!specs.some((spec) => eventMatches(event, spec))) return;
if (opts.preventDefault) event.preventDefault();
handlerRef.current(event);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [comboKey]);
}
module.exports = { useHotkeys };
Three ideas carry this. parseCombo turns the string into a spec — one boolean per modifier plus the key — so the required and un-required modifiers are both written down. eventMatches compares all four flags against that spec, which is what makes ctrl+k reject ctrl+shift+k. And the handler never enters the dependency array: it rides in handlerRef, refreshed on every render, so the effect keys on comboKey alone and subscribes exactly once. An array of combos is just a list of specs — the listener fires if the event matches any of them.
enableOnFormTags defaults off, and that default is load-bearing. If a bare / opened your search palette even while the user was mid-word in a text box, they could never type a slash. So before matching anything, the handler asks what event.target is: an input, textarea, select, or a contentEditable element means the keystroke belongs to the user, and the shortcut steps aside. Setting enableOnFormTags: true opts back in for the rare shortcut that should fire everywhere.
The handler you pass is almost always written inline — useHotkeys('ctrl+k', () => setOpen(true)) — so it is a different function object on every render. If the effect depended on it, React would tear the keydown listener down and add a new one every single render: churn you do not want on a global listener. The other way out — subscribe once with an empty-ish dependency and call the handler you captured — freezes the handler at its first value, so it reads stale state forever.
The fix is the useLatest pattern: hold the newest handler in a ref, refreshed each render, and have the listener read handlerRef.current when a key is actually pressed. The effect then depends only on comboKey, a string that changes when the combo changes and at no other time — so one listener stays subscribed across every render while always running the current handler. It is the same trick useEventCallback wraps into a stable function.
Take a command palette: useHotkeys('ctrl+k', () => setOpen(true)).
handlerRef.current is set to the inline function. comboKey is 'ctrl+k'. The effect runs, parses one spec { ctrl: true, meta: false, alt: false, shift: false, key: 'k' }, and adds one keydown listener to document.ctrl+shift+k. The event has ctrlKey true, shiftKey true. eventMatches compares shiftKey === spec.shift, i.e. true === false — no match. The palette stays shut. The naive version, which never checked shift, would have opened it.ctrl+k. All four flags line up (ctrl true, the rest false) and key is k. It matches, so handlerRef.current(event) runs and the palette opens.k in a search box. event.target is an input, enableOnFormTags is off, so the handler bails before matching. The letter lands in the box.handlerRef.current is repointed; the effect does not re-run because comboKey is unchanged. The next ctrl+k runs the new handler — fresh, with no re-subscribe.event.ctrlKey && event.key === 'k' fires on ctrl+shift+k too. Compare all four modifier flags against the combo so the un-required ones must be absent.event.target check, a bare-key hotkey eats the user's keystrokes in inputs. Skip form fields by default; gate it behind enableOnFormTags.handler. An inline handler is a new function every render, so the listener re-subscribes constantly. Key on the combo string and read the handler from a ref.event.code instead of event.key. event.code is the physical key position (KeyK), not the character, so it ignores layout and case. Use event.key and lower-case it, as this hook does. (react-hotkeys-hook defaults to code and adds a useKey option for the reverse.)preventDefault. ctrl+s triggers the browser Save dialog and ctrl+p triggers Print unless you call event.preventDefault(). Pass preventDefault: true for shortcuts that shadow a browser default.g then i to go to your issues. That needs a short-lived buffer of recent keys and a timeout, not a single-event match. react-hotkeys-hook writes these with a > separator (g>i).document, so the shortcut is global. Returning a ref and binding to that element instead scopes the shortcut to a panel, which is how you give a modal its own Escape without the page behind it reacting.mod key, cross-platform. mod maps to Cmd on macOS and Ctrl everywhere else, so one binding covers ⌘K and Ctrl+K. This hook detects the platform from navigator.userAgent; it is untested here precisely because the result depends on the host OS.useKeyPress, a boolean that tracks the key across keydown and keyup rather than binding a chord to a handler.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.