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:
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:
- No type safety: I could type “Cmnd+S” instead of “Cmd+S” and only discover the error at runtime
- Platform detection repeated everywhere:
(event.metaKey || event.ctrlKey)appeared in every hotkey - Complex modifier combinations:
event.key === 'z' && !event.shiftKeyis hard to read - 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:
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:
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:
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
// Then check (event.metaKey || event.ctrlKey) everywhereif ((isMac ? event.metaKey : event.ctrlKey) && event.key === 's') { saveDocument()}With TanStack Hotkeys, Mod automatically resolves to the correct key:
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:
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:
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:
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:
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:
| Aspect | Manual Approach | TanStack Hotkeys |
|---|---|---|
| Type safety | Runtime errors only | Compile-time validation |
| Platform support | Manual detection | Built-in Mod key |
| Key combinations | Complex boolean logic | Template strings |
| Held key tracking | Custom state management | Built-in hooks |
| Cleanup required | Manual event listeners | Automatic |
| 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:
- Type-safe hotkey registration with template string syntax catches errors at compile time
- Cross-platform Mod key eliminates platform detection code
- Key state tracking hooks (
useHeldKeys,useKeyHold) enable dynamic UI patterns - Conditional hotkeys prevent unwanted triggers
- 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