Reactのカスタムフックでキーボードショートカットを作る方法

​​React​Playwright,TypeScript,useEffect,useEffectEvent,カスタムフック

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)のどちらでもよいことにしました。

ts
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 は、メモの欄を空にするショートカットです。

tsx
// 「/」で検索欄へ移る。入力欄の中で打った「/」には反応しない
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回目/4回」などの表示が出る。/ を押すと検索欄が選ばれ、一覧に「/ → 検索欄へ」が足される。メモの欄に a/b と打っても一覧は増えない。Ctrl+K でコマンドパレットが開き、Esc で閉じる
/ で検索欄へ移り、メモの欄では反応せず、Ctrl+K で開いて Esc で閉じる動き

登録は1回にして、最新の値を読む

ショートカットで呼ぶ処理は、たいてい state を読みます。比べるために、L を押したときの count を記録する画面を、3通りの書き方で作りました。+1 のボタンを3回押してから L を押し、そのあいだに window に keydown のリスナーを登録した回数と、外した回数を数えています。

text
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 を押した結果です。

text
メモの欄で 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 のあり・なしを比べました。

text
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 で別に見ています。

text
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 を呼び、押したキーが移った先の入力欄に入らないようにします。

​Posted by 86