Reactのカスタムフックでキーボードショートカットを作る方法
React のカスタムフックを1つ作っておくと、「/」で検索欄へ移る、Ctrl+K でコマンドパレットを開く、といったショートカットを、1つにつきフックを1回呼ぶだけで足せます。window の keydown を useEffect で登録し、押されたキーの比べ方と、反応させない場面の判定をフックの中にまとめます。
筆者は検証環境で、検索欄・メモの欄・パレットのある小さな画面にショートカットを付け、Chromium に Playwright でキー入力を送って、動きを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 141 を Playwright 1.56.1 で操作しました。StrictMode は使っていません。Mac の実機や、ほかのブラウザでは試していません。
リスナーの登録は useEffect で1回だけ行い、押されたときの処理は useEffectEvent で包んで、最新の props や state を読みます。入力欄の中で押されたキー、押しっぱなしの繰り返し、日本語の変換中のキーは、ショートカットとして扱いません。
ショートカット用のフックの全体
検証に使ったフックです。'/'、'mod+k'、'shift+n' のような文字列でキーを指定します。mod は Ctrl と ⌘(Meta)のどちらでもよいことにしました。
import { useEffect, useEffectEvent } from 'react';
export type ShortcutOptions = {
enabled?: boolean; // false のあいだは登録しない
allowInInput?: boolean; // 入力欄で文字を打っているときも反応させるか
allowRepeat?: boolean; // 押しっぱなしの繰り返しにも反応させるか
preventDefault?: boolean; // ブラウザの既定の動きを止めるか
};
type Combo = { key: string; mod: boolean; shift: boolean; alt: boolean };
// "mod+k" や "shift+n" を分解する。mod は Ctrl と ⌘(Meta)のどちらでもよいことにする
function parseCombo(combo: string): Combo {
const parts = combo.toLowerCase().split('+');
const key = parts.pop() ?? '';
return { key, mod: parts.includes('mod'), shift: parts.includes('shift'), alt: parts.includes('alt') };
}
function matches(e: KeyboardEvent, c: Combo): boolean {
return (
e.key.toLowerCase() === c.key &&
(e.ctrlKey || e.metaKey) === c.mod &&
e.shiftKey === c.shift &&
e.altKey === c.alt
);
}
// 入力欄や contenteditable の中でキーを押したか
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || target.matches('input, textarea, select');
}
export function useKeyboardShortcut(
combo: string,
handler: (e: KeyboardEvent) => void,
{ enabled = true, allowInInput = false, allowRepeat = false, preventDefault = true }: ShortcutOptions = {},
) {
// 登録し直さずに、押した時点の最新の handler と設定を使う
const onKeyDown = useEffectEvent((e: KeyboardEvent) => {
// 日本語の変換中のキーは無視する(keyCode 229 は IME が処理中のキー)
if (e.isComposing || e.keyCode === 229) return;
if (e.repeat && !allowRepeat) return;
if (!allowInInput && isTyping(e.target)) return;
if (!matches(e, parseCombo(combo))) return;
if (preventDefault) e.preventDefault();
handler(e);
});
useEffect(() => {
if (!enabled) return;
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [enabled]);
}
使う側は、コンポーネントの中でフックを呼ぶだけです。次は検証に使った画面から、ショートカットの部分を抜き出したものです。Shift+N は、メモの欄を空にするショートカットです。
// 「/」で検索欄へ移る。入力欄の中で打った「/」には反応しない
useKeyboardShortcut(
'/',
() => {
searchRef.current?.focus();
record('/ → 検索欄へ');
},
{ preventDefault: slashPreventDefault },
);
// Ctrl+K(Mac では ⌘K)でパレットを開け閉めする。入力欄の中でも反応させる
useKeyboardShortcut(
'mod+k',
() => {
setOpen((o) => !o);
record(open ? 'Ctrl+K → 閉じる' : 'Ctrl+K → 開く');
},
{ allowInInput: true },
);
// Esc で閉じる。開いているあいだだけ登録する
useKeyboardShortcut(
'escape',
() => {
setOpen(false);
record('Esc → 閉じる');
},
{ enabled: open, allowInInput: true },
);
// Shift+N でメモを空にする
useKeyboardShortcut('shift+n', () => {
setMemo('');
record('Shift+N → メモを空に');
});
record は、反応したショートカットを画面の一覧に足す、検証用の関数です。slashPreventDefault は、あとで preventDefault のあり・なしを比べるための props で、ふだんは true です。

登録は1回にして、最新の値を読む
ショートカットで呼ぶ処理は、たいてい state を読みます。比べるために、L を押したときの count を記録する画面を、3通りの書き方で作りました。+1 のボタンを3回押してから L を押し、そのあいだに window に keydown のリスナーを登録した回数と、外した回数を数えています。
effect-event: {"count":"count: 3","logged":"L で記録した値: 3","added":1,"removed":0}、外したあとに登録中のリスナー 0
deps: {"count":"count: 3","logged":"L で記録した値: 3","added":5,"removed":4}、外したあとに登録中のリスナー 0
empty-deps: {"count":"count: 3","logged":"L で記録した値: 0","added":1,"removed":0}、外したあとに登録中のリスナー 0
1行目が、この記事のフックです。登録は1回だけで、L を押したときの値は 3 でした。2行目は、handler を依存配列に入れた書き方です。値は正しいものの、描画のたびに handler が新しい関数になるので、最初の登録のあと、+1 の3回と、L で記録した値が変わったときの描画で、計4回、解除して登録し直しました。3行目は、依存配列を空にした書き方です。登録は1回ですが、最初の描画のときの count を読み続けて、0 を記録しました。どの書き方でも、コンポーネントを外したあとに残ったリスナーはありませんでした。
useEffectEvent は React 19.2 で入ったフックです(React 19.2 の発表記事)。公式リファレンスの、カスタムフックの中で使う例でも、受け取った callback を useEffectEvent で包み、毎回新しい関数が渡されても useEffect をやり直さないようにしています。同じページの注意書きにあるとおり、包んだ関数は描画のたびに別の関数になるので、依存配列には入れません。登録と解除は、公式リファレンスにある、window のイベントリスナーで使う例と同じく、同じ useEffect の中で同じ関数を使って行います。依存配列の3通りの違いは、以前の記事「ReactのuseEffectEventでエフェクトをやり直さずに最新の値を読む方法」でも比べています。
Esc のように、パレットが開いているあいだだけ効かせたいショートカットは、enabled で登録そのものを切り替えます。検証では、パレットを開く前に登録されていたリスナーは3つ(/、Ctrl+K、Shift+N)で、開いているあいだは4つ、閉じたあとは3つに戻りました。
入力欄の中では反応させない
window で keydown を受けると、入力欄で文字を打ったときのキーも届きます。MDN の keydown のページによると、イベントの target はフォーカスのある要素で、入力欄や contenteditable の要素も含まれます。そこで isTyping で target を調べ、入力欄の中なら何もしないようにしました。
メモの欄をクリックして a/b と打ち、続けて Shift+N を押した結果です。
メモの欄で a/b と Shift+N を打ったあと: {"focused":"memo","search":"","memo":"a/bN","dialog":false,"log":[]}
「/」も Shift+N もショートカットにはならず、そのまま文字としてメモに入りました。いっぽう、Ctrl+K と Esc は allowInInput: true にしてあります。メモの欄にいても Ctrl+K でパレットが開き、Meta+K(Mac の ⌘K にあたるキー)で閉じました。もう一度 Ctrl+K で開いてから、メモの欄で Esc を押したときも、パレットは閉じました。文字を打つときに使わない組み合わせだけを、入力欄の中でも効かせるようにしています。
「/」で移るときは、既定の動きを止める
何も選んでいない状態で「/」を押し、preventDefault のあり・なしを比べました。
preventDefault あり: {"focused":"search","search":"","memo":"","dialog":false,"log":["/ → 検索欄へ"]}
preventDefault なし: {"focused":"search","search":"/","memo":"","dialog":false,"log":["/ → 検索欄へ"]}
どちらも検索欄に移りましたが、preventDefault を呼ばないと、押した「/」がそのまま検索欄に入りました。keydown の中でフォーカスを移したので、押した「/」の文字が、移った先の検索欄に入力されたと考えられます。このため、フックでは preventDefault を呼ぶほうを既定にしました。Ctrl+K と Esc でも preventDefault が呼ばれたこと(defaultPrevented が true)は確かめましたが、ブラウザ本体の Ctrl+K の動きが止まるかどうかは、この検証では確かめていません。
キーは key で比べ、修飾キーも合わせて見る
MDN の KeyboardEvent.key のページによると、key は Shift などの修飾キーと、キーボードの配列を反映した値です。検証でも、Shift+N を押したときの key は "N" でした。そのため、toLowerCase() で小文字にそろえてから比べ、Shift は shiftKey で別に見ています。
k と n だけを押したあと: {"focused":"BODY","search":"","memo":"","dialog":false,"log":[]}
Shift+N を押したあと: {"focused":"BODY","search":"","memo":"","dialog":false,"log":["Shift+N → メモを空に"]}
修飾キーの有無まで比べているので、k や n だけを押してもショートカットにはなりません。mod は ctrlKey || metaKey で比べていて、Linux の Chromium で Meta+K を押したときも Ctrl+K と同じように反応しました。Mac で ⌘K を押したときの動きは試していません。
キーの位置で決めたいとき(ゲームの WASD など)は、MDN の KeyboardEvent.code のページにあるとおり、配列に左右されない code を使う方法もあります。この記事のフックは、キーに書かれた文字で指定したいので key を使いました。英語配列以外のキーボードでの動きは試していません。また、このフックは Shift の有無まで比べるので、Shift を押して打つ記号(? など)は、この書き方では指定できません。
押しっぱなしと、日本語の変換中は無視する
キーを押し続けると、keydown が繰り返し届きます。MDN の KeyboardEvent.repeat のページによると、そのときの repeat は true です。Ctrl を押したまま K の keydown を3回送ったところ、2回目と3回目は repeat: true になり、パレットは1回だけ開きました。開け閉めするショートカットで繰り返しに反応すると、押している長さで結果が変わってしまいます。カーソルを動かすショートカットのように、繰り返してほしいときは allowRepeat: true にします。なお、このフックは繰り返しの keydown では preventDefault も呼びません。検証でも、2回目と3回目の defaultPrevented は false でした。
日本語を入力しているときは、変換中に押したキーにも keydown が届きます。MDN の keydown のページにある IME の説明では、変換中の keydown を無視するために、isComposing に加えて keyCode が 229 かどうかも見るよう勧めています。変換の始まりと終わりのキーでは、isComposing が false のまま届くことがあるためです。keyCode は非推奨ですが、MDN はこの場面に限って、あわせて確かめるのがよいとしています。
検証では、isComposing: true や keyCode: 229 を付けて合成した keydown を送ると、Ctrl+K でもパレットが開きませんでした。同じ合成のイベントでも、2つを付けなければパレットは開きました。この環境では日本語入力(IME)を動かせなかったので、実際の変換中の動きは確かめていません。
入力欄で効かせるキーと、既定の動きを止めるキー
「/」や Shift+N のように、文字を打つときにも使うキーは、入力欄の中では反応させません。Ctrl+K や Esc のように文字入力に使わない組み合わせだけを、allowInInput: true にして入力欄の中でも効かせます。「/」のようにフォーカスを移すショートカットでは preventDefault を呼び、押したキーが移った先の入力欄に入らないようにします。





