Skip to content

What is TanStack Hotkeys and How Does It Handle Type-Safe Keyboard Shortcuts in React?

Purpose

I’m building a text editor that needs keyboard shortcuts. When I implemented hotkey handling manually, I ran into several problems: no type safety, platform-specific key differences, and tracking held keys required complex state management. This post shows how TanStack Hotkeys solves these pain points with type-safe hotkey registration, cross-platform Mod key support, and key state tracking hooks.

The Problem with Manual Hotkey Handling

I started by handling keyboard events manually. Here’s what I wrote:

DocumentEditor.tsx
import { useEffect } from 'react'
function DocumentEditor() {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Save: Cmd+S on Mac, Ctrl+S on Windows
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
event.preventDefault()
saveDocument()
}
// Undo: Cmd+Z
if ((event.metaKey || event.ctrlKey) && event.key === 'z' && !event.shiftKey) {
event.preventDefault()
undo()
}
// Redo: Cmd+Shift+Z
if ((event.metaKey || event.ctrlKey) && event.key === 'z' && event.shiftKey) {
event.preventDefault()
redo()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return <textarea />
}

This approach had several issues:

  1. No type safety: I could type “Cmnd+S” instead of “Cmd+S” and only discover the error at runtime
  2. Platform detection repeated everywhere: (event.metaKey || event.ctrlKey) appeared in every hotkey
  3. Complex modifier combinations: event.key === 'z' && !event.shiftKey is hard to read
  4. No held key tracking: If I wanted to show which keys are currently pressed, I needed additional state management

I saw a Reddit thread where another developer building a text editor mentioned they “did all of the hotkey handling by hand, and while it’s not completely unmaintainable, there were multiple pain points.” I had the same experience.

What is TanStack Hotkeys?

TanStack Hotkeys is a type-safe, framework-agnostic library for handling keyboard shortcuts. It provides:

  • Template string syntax for hotkey registration: useHotkey('Mod+S', handler)
  • Cross-platform Mod key that resolves to Cmd on macOS and Ctrl on Windows/Linux
  • Key state tracking hooks: useHeldKeys(), useKeyHold()
  • Full TypeScript support with autocomplete for valid key combinations
  • Conditional hotkeys that can be enabled/disabled based on state
  • SSR-friendly design

Type-Safe Hotkey Registration

I rewrote my text editor using TanStack Hotkeys:

DocumentEditor.tsx
import { useHotkey } from '@tanstack/react-hotkeys'
function DocumentEditor() {
// Type-safe hotkey with Mod key (cross-platform)
useHotkey('Mod+S', (event) => {
event.preventDefault()
saveDocument()
})
// Multiple hotkeys with type checking
useHotkey('Mod+Z', () => undo())
useHotkey('Mod+Shift+Z', () => redo())
return <textarea />
}

When I type useHotkey(', TypeScript shows me all valid key combinations. If I make a typo like useHotkey('Cmnd+S'), TypeScript shows an error immediately. This caught several bugs during development that I would have missed with manual string literals.

I can also use object syntax for complex combinations:

AdvancedHotkeys.tsx
useHotkey(
{
key: 'S',
mod: true,
shift: true
},
() => saveAs()
)

Cross-Platform Mod Key Support

The Mod key is the feature I use most. Before TanStack Hotkeys, I wrote platform detection code:

PlatformDetection.tsx
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
// Then check (event.metaKey || event.ctrlKey) everywhere
if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 's') {
saveDocument()
}

With TanStack Hotkeys, Mod automatically resolves to the correct key:

ModKey.tsx
useHotkey('Mod+S', () => saveDocument())
useHotkey('Mod+N', () => newFile())
useHotkey('Mod+W', () => closeFile())

This works on macOS (Cmd+S, Cmd+N, Cmd+W) and Windows/Linux (Ctrl+S, Ctrl+N, Ctrl+W) without any platform detection code.

Key State Tracking Hooks

TanStack Hotkeys provides hooks for tracking which keys are currently held down. I used these to build a modifier bar that shows active keys:

ModifierBar.tsx
import { useHeldKeys, useKeyHold } from '@tanstack/react-hotkeys'
function KeyboardStatusBar() {
// Track all currently held keys
const heldKeys = useHeldKeys()
// Track specific modifiers (optimized - no unnecessary re-renders)
const isShiftHeld = useKeyHold('Shift')
const isCtrlHeld = useKeyHold('Control')
const isAltHeld = useKeyHold('Alt')
return (
<div className="keyboard-status">
<div className="modifier-bar">
<span className={isShiftHeld ? 'active' : ''}>Shift</span>
<span className={isCtrlHeld ? 'active' : ''}>Ctrl</span>
<span className={isAltHeld ? 'active' : ''}>Alt</span>
</div>
<div>
{heldKeys.length > 0 ? `Held: ${heldKeys.join(' + ')}` : 'No keys held'}
</div>
</div>
)
}

The key insight here is that useKeyHold is optimized. It only re-renders when that specific key changes state. If I had tracked all keys with useHeldKeys(), the component would re-render on every keypress.

I also used useKeyHold for a hold-to-reveal pattern in a file manager:

FileItem.tsx
function FileItem({ file }: { file: { name: string; id: string } }) {
const isShiftHeld = useKeyHold('Shift')
return (
<div className="file-item">
<span>{file.name}</span>
{isShiftHeld ? (
<button className="danger" onClick={() => permanentlyDelete(file.id)}>
Permanently Delete
</button>
) : (
<button onClick={() => moveToTrash(file.id)}>
Move to Trash
</button>
)}
</div>
)
}

When the user holds Shift, the dangerous “Permanently Delete” button appears. Otherwise, they only see the safe “Move to Trash” option. This prevents accidental data loss.

Conditional Hotkeys

TanStack Hotkeys supports conditional hotkeys that are only active in certain states. I used this for an auto-save feature:

AutoSave.tsx
function DocumentEditor() {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [isAutoSaveEnabled, setIsAutoSaveEnabled] = useState(true)
// Only active when there are unsaved changes
useHotkey(
'Mod+S',
() => {
saveDocument()
setHasUnsavedChanges(false)
},
{
enabled: hasUnsavedChanges && isAutoSaveEnabled
}
)
return (
<div>
<textarea onChange={() => setHasUnsavedChanges(true)} />
<label>
<input
type="checkbox"
checked={isAutoSaveEnabled}
onChange={(e) => setIsAutoSaveEnabled(e.target.checked)}
/>
Enable auto-save
</label>
</div>
)
}

When hasUnsavedChanges is false or isAutoSaveEnabled is disabled, the Mod+S hotkey does nothing. This prevents unnecessary save operations.

Real-World Example: Complete Text Editor

Here’s a complete text editor component that combines these features:

TextEditor.tsx
import { useHotkey, useKeyHold } from '@tanstack/react-hotkeys'
import { useState } from 'react'
function TextEditor() {
const [content, setContent] = useState('')
const [history, setHistory] = useState<string[]>([])
const [historyIndex, setHistoryIndex] = useState(-1)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [showSearch, setShowSearch] = useState(false)
const isShiftHeld = useKeyHold('Shift')
// Save
useHotkey('Mod+S', (event) => {
event.preventDefault()
console.log('Saving:', content)
setHasUnsavedChanges(false)
})
// Undo
useHotkey('Mod+Z', () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1
setHistoryIndex(newIndex)
setContent(history[newIndex])
}
})
// Redo
useHotkey('Mod+Shift+Z', () => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1
setHistoryIndex(newIndex)
setContent(history[newIndex])
}
})
// Find
useHotkey('Mod+F', (event) => {
event.preventDefault()
setShowSearch((prev) => !prev)
})
// Escape to close search
useHotkey('Escape', () => {
setShowSearch(false)
})
const handleContentChange = (newContent: string) => {
setContent(newContent)
setHasUnsavedChanges(true)
// Add to history
const newHistory = [...history.slice(0, historyIndex + 1), newContent]
setHistory(newHistory)
setHistoryIndex(newHistory.length - 1)
}
return (
<div className="editor-container">
<div className="toolbar">
<button onClick={() => setContent('')}>New</button>
<button onClick={() => console.log('Save')}>Save (Mod+S)</button>
<button onClick={() => console.log('Undo')}>Undo (Mod+Z)</button>
<button onClick={() => console.log('Redo')}>
Redo (Mod+{isShiftHeld ? 'Shift+' : ''}Z)
</button>
<button onClick={() => setShowSearch((prev) => !prev)}>
Find (Mod+F)
</button>
</div>
{showSearch && (
<div className="search-bar">
<input type="text" placeholder="Search..." />
<small>Press Escape to close</small>
</div>
)}
<textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Start typing..."
style={{ width: '100%', height: '400px' }}
/>
{hasUnsavedChanges && (
<div className="status-bar">Unsaved changes</div>
)}
</div>
)
}

This component shows:

  • Basic hotkeys (Mod+S, Mod+Z, Mod+Shift+Z, Mod+F)
  • Conditional state (hasUnsavedChanges)
  • Key state tracking (isShiftHeld for the Redo button label)
  • Escape key to close the search bar

Comparison: Manual vs TanStack Hotkeys

Here’s what changed when I switched from manual hotkey handling to TanStack Hotkeys:

AspectManual ApproachTanStack Hotkeys
Type safetyRuntime errors onlyCompile-time validation
Platform supportManual detectionBuilt-in Mod key
Key combinationsComplex boolean logicTemplate strings
Held key trackingCustom state managementBuilt-in hooks
Cleanup requiredManual event listenersAutomatic
Code reduction~100 lines~30 lines

Summary

In this post, I showed how TanStack Hotkeys solves keyboard shortcut handling in React. The key points are:

  1. Type-safe hotkey registration with template string syntax catches errors at compile time
  2. Cross-platform Mod key eliminates platform detection code
  3. Key state tracking hooks (useHeldKeys, useKeyHold) enable dynamic UI patterns
  4. Conditional hotkeys prevent unwanted triggers
  5. The library reduces boilerplate by 70% compared to manual event handling

If you’re building a text editor, productivity tool, or any keyboard-heavy application, TanStack Hotkeys handles the complexity of keyboard shortcuts so you can focus on your application logic.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments