TanStack Hotkeys を導入した
2026/3/31
このポートフォリオには cmdk ベースのコマンドパレットがあり、Cmd+K で開けます。しかし、それ以外のキーボード操作は何もありませんでした。テーマ切替はマウスクリックのみ、ブログ記事間の移動はブラウザバックのみ。開発者向けポートフォリオとしては物足りません。
Cmd+K は生の addEventListener で実装していたので、@tanstack/react-hotkeys に移行しつつショートカットを増やすことにしました。API がシンプルで、TanStack エコシステムとの一貫性もあります。
@tanstack/react-hotkeys の設計を理解する
実装の前に、ライブラリの内部動作を調べました。
入力要素の自動無視
@tanstack/hotkeys のコアには shouldIgnoreInputEvent という関数があります。input、textarea、contentEditable 要素にフォーカスがあるとき、ホットキーの発火を抑制します。この挙動はキーの種類によって自動的に切り替わります。
- 修飾キー付き(
Mod+Kなど): 入力要素内でも発火する(ignoreInputs: false) Escape: 入力要素内でも発火する- 単一キー(
T、ArrowLeftなど): 入力要素内では無視される(ignoreInputs: true)
この自動判定のおかげで、コマンドパレットの検索欄に t と打ったときにテーマが切り替わる、といった事故が起きません。手動で e.target.tagName をチェックする必要がないのは大きいです。
preventDefault のデフォルト有効
生の addEventListener では e.preventDefault() を手動で呼ぶ必要がありました。@tanstack/react-hotkeys ではデフォルトで preventDefault: true になっています。コールバックがすっきりします。
キー名の正規化
キー名は KeyboardEvent.key の標準値に正規化されます。t → T、left → ArrowLeft、esc → Escape。さらに event.code へのフォールバックがあり、macOS で Option キーを押しながらの入力や Dead Key イベントでも正しくマッチします。
実装した5つのショートカット
実際の動作の一部です。コマンドパレットの起動やテーマ切替がキーボードだけで完結します。
1. Cmd+K の移行
生の addEventListener から useHotkey への移行です。10行のボイラープレートが1行になりました。
// Before: useEffect + addEventListener + cleanup
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// After: 1行
useHotkey("Mod+K", () => setOpen((prev) => !prev));
Mod は macOS では Meta(Command)、Windows/Linux では Control に自動解決されます。クロスプラットフォーム対応がキー名一つで済みます。
2. テーマ切替 T
useCallback でトグルロジックを抽出し、onClick と useHotkey の両方から呼んでいます。
const switchTheme = useCallback(() => {
const next = resolvedTheme === "dark" ? "light" : "dark";
playSound(next === "dark" ? "/audio/switch-on.mp3" : "/audio/switch-off.mp3");
setTheme(next);
}, [resolvedTheme, setTheme]);
useHotkey("T", switchTheme);
単一キーなので、コマンドパレットの検索欄やその他の入力フィールドでは自動的に無視されます。
3. ブログ記事ナビゲーション ← →
ブログ記事の詳細ページに前後記事への導線が一切なかったのは、ショートカット以前にUXの問題でした。ArrowLeft/ArrowRight のホットキーに加えて、UIとしてもナビゲーションリンクを追加しています。
useHotkey("ArrowLeft", () => {
if (previousPost) {
navigate({ to: "/blog/$slug", params: { slug: previousPost.slug } });
}
});
記事は日付降順でソートしているので、currentIndex + 1 が古い記事(前)、currentIndex - 1 が新しい記事(次)になります。
4. コマンドパレットのショートカットヒント
cmdk のラッパーには CommandShortcut コンポーネントが定義済みでしたが、未使用でした。テーマ切替アイテムの横に T と表示するだけで、ショートカットの発見性が上がります。
<CommandItem onSelect={...}>
<SunIcon />
テーマ切替
<CommandShortcut>T</CommandShortcut>
</CommandItem>
5. ショートカットヘルプダイアログ ?
GitHub、Linear、Notion など開発者向けサービスでは ? でショートカット一覧が表示されます。同じパターンを実装しました。
ここで一つ型の問題がありました。Shift+/ は TypeScript の型エラーになります。@tanstack/hotkeys の Hotkey 型は、Shift と PunctuationKey(/ を含む)の組み合わせを明示的に除外しています。Shift を押すと event.key がレイアウト依存で変わるためです(US配列では / → ?、他の配列では異なる可能性がある)。
解決策は RawHotkey オブジェクト形式を使うことです。
// 型エラー: Shift+PunctuationKey は Hotkey 型に含まれない
useHotkey("Shift+/", callback);
// OK: RawHotkey オブジェクト形式ならキーを個別に指定できる
useHotkey({ key: "/", shift: true }, callback);
内部的には event.code(Slash)へのフォールバックで物理キーを判定するため、キーボードレイアウトに依存しません。
振り返り
@tanstack/react-hotkeys を選んだ判断は正しかったと思います。API がシンプルで、入力要素の無視や preventDefault が自動化されており、ボイラープレートが大幅に減りました。TanStack エコシステムに統一できる点も保守性に効きます。
一方で型システムの制約(Shift+PunctuationKey の除外)は実装時に初めて気づきました。ライブラリの型定義を読んで RawHotkey 形式に切り替えるまで少し時間がかかっています。型安全は安心感をくれますが、「なぜこの組み合わせが型エラーなのか」を理解するには内部実装の知識が必要でした。