Build a hook that reports whether a particular keyboard key is currently held down. Plenty of UI reacts to a key being down, not just tapped: showing a hint while the user holds Shift, panning a canvas while Space is pressed, highlighting a shortcut while a modifier is active. useKeyPress(targetKey) returns a boolean that is true from the moment targetKey is pressed until it is released, and false the rest of the time.
The key you track is compared against the event.key value of keyboard events — so 'a', 'Enter', 'Escape', and ' ' (space) are all valid targets. Matching is exact and case-sensitive: 'a' and 'A' are different keys.
function useKeyPress(targetKey: string): boolean;
It takes the event.key string to track and returns whether that key is down right now. It returns false on the first render, before any key is pressed.
function ShiftHint() {
const shiftHeld = useKeyPress('Shift');
return <p>{shiftHeld ? 'Release Shift to continue' : 'Hold Shift for options'}</p>;
}
// Tracking the letter 'a':
// before any key: false
// keydown { key: 'a' } -> true
// keyup { key: 'a' } -> false
// keydown { key: 'b' } -> false (different key, ignored)
true only while the key is held. Pressing it sets true; releasing it sets false. A keydown-only listener gets stuck at true forever.window. Attach the listeners to window so the key state is tracked regardless of which element has focus.event.key exactly. Compare against event.key, case-sensitive. 'a' and 'A' (Shift held) are distinct, as are 'Enter' and 'Escape'.targetKey must not change the result.targetKey changes — otherwise listeners leak or track the wrong key.You'll keep a boolean in state, flip it to true on a keydown for the target key and back to false on the matching keyup, and wire both listeners up — and tear them down — inside one effect.
A key being held is a span of time with two edges: the moment it goes down and the moment it comes back up. Tracking "is this key down right now" means catching both edges. The browser gives you a keydown event when the key is pressed and a separate keyup event when it's released. If you only watch one of them, you only ever see half the story — you can learn that a key went down but never that it came up. So the hook has to listen for two events at once, keep a single boolean in sync with them, and only react when the event's key matches the one you care about.
Think of the boolean as a tiny state machine with two states, false (up) and true (down), and two transitions between them. A keydown whose event.key equals targetKey moves you from false to true. A keyup whose event.key equals targetKey moves you back from true to false. Every other event — a different key, or an event you don't listen for — leaves the state exactly where it was.
The two transitions map directly onto two listeners: one for keydown, one for keyup. Both live on window, and both ignore events whose event.key isn't your targetKey.
The obvious version watches for the key being pressed:
const { useState, useEffect } = require('react');
function useKeyPress(targetKey) {
const [pressed, setPressed] = useState(false);
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === targetKey) setPressed(true);
};
window.addEventListener('keydown', onKeyDown);
}, [targetKey]);
return pressed;
}
This catches the press but never the release, so once pressed becomes true it is stuck there forever — there is no transition back to false. The user lifts their finger and the hook still claims the key is down. There's a second bug too: the effect never returns a cleanup, so the listener is never removed on unmount, and a new one stacks on top every time targetKey changes. The fix is to add the missing edge — a keyup listener — and to clean both listeners up.
const { useState, useEffect } = require('react');
function useKeyPress(targetKey) {
// The single source of truth: is targetKey down right now? Starts false —
// nothing is pressed before the first event arrives.
const [pressed, setPressed] = useState(false);
useEffect(() => {
// The down edge: only react when the pressed key is the one we track.
const onKeyDown = (event) => {
if (event.key === targetKey) setPressed(true);
};
// The up edge: the transition the naive version was missing.
const onKeyUp = (event) => {
if (event.key === targetKey) setPressed(false);
};
// Listen on window so focus doesn't matter — keys are tracked globally.
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
// Remove BOTH listeners on unmount, and before re-subscribing when
// targetKey changes — otherwise listeners leak or track a stale key.
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [targetKey]);
return pressed;
}
module.exports = { useKeyPress };
The shift from the naive version is two things at once. First, a second listener for keyup supplies the missing transition, so pressed can travel back to false when the key is released. Second, the effect now returns a cleanup that removes both listeners, and the dependency array [targetKey] means that whenever the tracked key changes, React runs the cleanup (removing the old listeners) and then re-runs the effect (attaching fresh ones bound to the new key). The same named functions are passed to both addEventListener and removeEventListener, so the removal actually matches what was added.
The naive version treats "the key is pressed" as a one-way event. The working version treats it as a two-edged span: down sets true, up sets false. That second edge is the whole fix. Everything else — the event.key === targetKey guards, the window target, the cleanup — is plumbing that keeps the two edges correct and contained.
Take useKeyPress('a') and follow the keyboard:
useState(false) makes pressed start at false. The effect runs: onKeyDown and onKeyUp are attached to window. The hook returns false.a. A keydown with event.key === 'a' fires. onKeyDown sees the match and calls setPressed(true). React re-renders and the hook returns true.b while still holding a. A keydown with event.key === 'b' fires. onKeyDown checks 'b' === 'a', which is false, so it does nothing. The hook still returns true.a. A keyup with event.key === 'a' fires. onKeyUp sees the match and calls setPressed(false). The hook returns false again.window. No later key event touches this component.At no point does an event for a non-'a' key change the result, because both handlers guard on event.key === targetKey.
keydown only. With no keyup handler, pressed rises to true and never comes back down — the hook reports the key as held forever after the first press. Fix: add a keyup listener that sets pressed to false.event.keyCode is deprecated and event.code is the physical key position ('KeyA'), not the character. Tracking 'a' against event.code never matches. Fix: compare against event.key, which holds the produced value ('a', 'A', 'Enter').return () => removeEventListener(...), the listeners leak on unmount and a fresh pair stacks on top every time targetKey changes. Fix: return a cleanup that removes both listeners, and pass the same function references you added.event.key for a with Shift held is 'A', a different string from 'a'. Tracking 'a' will not fire while Shift is down. Fix: pick the exact event.key value you mean, or normalize case yourself if that's the behavior you want.useKeyPress(targetKeys: string[]) returning a map of which tracked keys are down, so you can detect combos like Shift + ? without one hook per key.useKeyDown(targetKey, handler) could fire a callback on the press edge rather than exposing held state — useful for "trigger an action on Escape" where you don't need the boolean.keyup may never arrive and the key stays "stuck." Listening for the window blur event to reset pressed to false closes that gap.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a hook that reports whether a particular keyboard key is currently held down. Plenty of UI reacts to a key being down, not just tapped: showing a hint while the user holds Shift, panning a canvas while Space is pressed, highlighting a shortcut while a modifier is active. useKeyPress(targetKey) returns a boolean that is true from the moment targetKey is pressed until it is released, and false the rest of the time.
The key you track is compared against the event.key value of keyboard events — so 'a', 'Enter', 'Escape', and ' ' (space) are all valid targets. Matching is exact and case-sensitive: 'a' and 'A' are different keys.
function useKeyPress(targetKey: string): boolean;
It takes the event.key string to track and returns whether that key is down right now. It returns false on the first render, before any key is pressed.
function ShiftHint() {
const shiftHeld = useKeyPress('Shift');
return <p>{shiftHeld ? 'Release Shift to continue' : 'Hold Shift for options'}</p>;
}
// Tracking the letter 'a':
// before any key: false
// keydown { key: 'a' } -> true
// keyup { key: 'a' } -> false
// keydown { key: 'b' } -> false (different key, ignored)
true only while the key is held. Pressing it sets true; releasing it sets false. A keydown-only listener gets stuck at true forever.window. Attach the listeners to window so the key state is tracked regardless of which element has focus.event.key exactly. Compare against event.key, case-sensitive. 'a' and 'A' (Shift held) are distinct, as are 'Enter' and 'Escape'.targetKey must not change the result.targetKey changes — otherwise listeners leak or track the wrong key.You'll keep a boolean in state, flip it to true on a keydown for the target key and back to false on the matching keyup, and wire both listeners up — and tear them down — inside one effect.
A key being held is a span of time with two edges: the moment it goes down and the moment it comes back up. Tracking "is this key down right now" means catching both edges. The browser gives you a keydown event when the key is pressed and a separate keyup event when it's released. If you only watch one of them, you only ever see half the story — you can learn that a key went down but never that it came up. So the hook has to listen for two events at once, keep a single boolean in sync with them, and only react when the event's key matches the one you care about.
Think of the boolean as a tiny state machine with two states, false (up) and true (down), and two transitions between them. A keydown whose event.key equals targetKey moves you from false to true. A keyup whose event.key equals targetKey moves you back from true to false. Every other event — a different key, or an event you don't listen for — leaves the state exactly where it was.
The two transitions map directly onto two listeners: one for keydown, one for keyup. Both live on window, and both ignore events whose event.key isn't your targetKey.
The obvious version watches for the key being pressed:
const { useState, useEffect } = require('react');
function useKeyPress(targetKey) {
const [pressed, setPressed] = useState(false);
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === targetKey) setPressed(true);
};
window.addEventListener('keydown', onKeyDown);
}, [targetKey]);
return pressed;
}
This catches the press but never the release, so once pressed becomes true it is stuck there forever — there is no transition back to false. The user lifts their finger and the hook still claims the key is down. There's a second bug too: the effect never returns a cleanup, so the listener is never removed on unmount, and a new one stacks on top every time targetKey changes. The fix is to add the missing edge — a keyup listener — and to clean both listeners up.
const { useState, useEffect } = require('react');
function useKeyPress(targetKey) {
// The single source of truth: is targetKey down right now? Starts false —
// nothing is pressed before the first event arrives.
const [pressed, setPressed] = useState(false);
useEffect(() => {
// The down edge: only react when the pressed key is the one we track.
const onKeyDown = (event) => {
if (event.key === targetKey) setPressed(true);
};
// The up edge: the transition the naive version was missing.
const onKeyUp = (event) => {
if (event.key === targetKey) setPressed(false);
};
// Listen on window so focus doesn't matter — keys are tracked globally.
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
// Remove BOTH listeners on unmount, and before re-subscribing when
// targetKey changes — otherwise listeners leak or track a stale key.
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, [targetKey]);
return pressed;
}
module.exports = { useKeyPress };
The shift from the naive version is two things at once. First, a second listener for keyup supplies the missing transition, so pressed can travel back to false when the key is released. Second, the effect now returns a cleanup that removes both listeners, and the dependency array [targetKey] means that whenever the tracked key changes, React runs the cleanup (removing the old listeners) and then re-runs the effect (attaching fresh ones bound to the new key). The same named functions are passed to both addEventListener and removeEventListener, so the removal actually matches what was added.
The naive version treats "the key is pressed" as a one-way event. The working version treats it as a two-edged span: down sets true, up sets false. That second edge is the whole fix. Everything else — the event.key === targetKey guards, the window target, the cleanup — is plumbing that keeps the two edges correct and contained.
Take useKeyPress('a') and follow the keyboard:
useState(false) makes pressed start at false. The effect runs: onKeyDown and onKeyUp are attached to window. The hook returns false.a. A keydown with event.key === 'a' fires. onKeyDown sees the match and calls setPressed(true). React re-renders and the hook returns true.b while still holding a. A keydown with event.key === 'b' fires. onKeyDown checks 'b' === 'a', which is false, so it does nothing. The hook still returns true.a. A keyup with event.key === 'a' fires. onKeyUp sees the match and calls setPressed(false). The hook returns false again.window. No later key event touches this component.At no point does an event for a non-'a' key change the result, because both handlers guard on event.key === targetKey.
keydown only. With no keyup handler, pressed rises to true and never comes back down — the hook reports the key as held forever after the first press. Fix: add a keyup listener that sets pressed to false.event.keyCode is deprecated and event.code is the physical key position ('KeyA'), not the character. Tracking 'a' against event.code never matches. Fix: compare against event.key, which holds the produced value ('a', 'A', 'Enter').return () => removeEventListener(...), the listeners leak on unmount and a fresh pair stacks on top every time targetKey changes. Fix: return a cleanup that removes both listeners, and pass the same function references you added.event.key for a with Shift held is 'A', a different string from 'a'. Tracking 'a' will not fire while Shift is down. Fix: pick the exact event.key value you mean, or normalize case yourself if that's the behavior you want.useKeyPress(targetKeys: string[]) returning a map of which tracked keys are down, so you can detect combos like Shift + ? without one hook per key.useKeyDown(targetKey, handler) could fire a callback on the press edge rather than exposing held state — useful for "trigger an action on Escape" where you don't need the boolean.keyup may never arrive and the key stays "stuck." Listening for the window blur event to reset pressed to false closes that gap.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.