Lesson 4: React Integration
Use KeyProvider and hooks to manage keyboard shortcuts in React applications.
Lesson 4 of 8 — Use KeyProvider and hooks to manage keyboard shortcuts in React applications.
Overview
duck-vim's React layer wraps the core system in a context provider and exposes hooks for ergonomic usage. The provider creates a shared Registry, KeyHandler, and SequenceManager, attaches event listeners on mount, and cleans everything up on unmount.
Setting up the provider
Wrap your app (or the relevant subtree) with KeyProvider:
import { KeyProvider } from '@gentleduck/vim/react'
export default function Root() {
return (
<KeyProvider
debug={process.env.NODE_ENV === 'development'}
timeoutMs={600}
>
<App />
</KeyProvider>
)
}import { KeyProvider } from '@gentleduck/vim/react'
export default function Root() {
return (
<KeyProvider
debug={process.env.NODE_ENV === 'development'}
timeoutMs={600}
>
<App />
</KeyProvider>
)
}Props:
| Prop | Type | Default | Description |
|---|---|---|---|
debug | boolean | false | Log key events and matches to console |
timeoutMs | number | 600 | Timeout for multi-key sequences |
defaultOptions | Partial<KeyBindOptions> | -- | Default options for all bindings |
useKeyBind: Single binding
The simplest hook. Register one binding with one handler:
import { useKeyBind } from '@gentleduck/vim/react'
function SearchBar() {
const [focused, setFocused] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useKeyBind('/', () => {
inputRef.current?.focus()
setFocused(true)
}, { ignoreInputs: true })
useKeyBind('escape', () => {
inputRef.current?.blur()
setFocused(false)
}, { enabled: focused })
return <input ref={inputRef} placeholder="Search..." />
}import { useKeyBind } from '@gentleduck/vim/react'
function SearchBar() {
const [focused, setFocused] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useKeyBind('/', () => {
inputRef.current?.focus()
setFocused(true)
}, { ignoreInputs: true })
useKeyBind('escape', () => {
inputRef.current?.blur()
setFocused(false)
}, { enabled: focused })
return <input ref={inputRef} placeholder="Search..." />
}Key points:
- The handler is always called with the latest closure (it's stored in a ref internally).
- Use
enabledto conditionally activate bindings. - Use
ignoreInputs: trueto skip when the user is typing in a text field.
useKeyCommands: Bulk registration
Register multiple bindings at once with a record:
import { useKeyCommands } from '@gentleduck/vim/react'
function Navigation() {
const navigate = useNavigate()
useKeyCommands({
'g+d': {
name: 'Go to Dashboard',
execute: () => navigate('/dashboard'),
},
'g+s': {
name: 'Go to Settings',
execute: () => navigate('/settings'),
},
'g+p': {
name: 'Go to Profile',
execute: () => navigate('/profile'),
},
}, { ignoreInputs: true })
return null // This component just registers shortcuts
}import { useKeyCommands } from '@gentleduck/vim/react'
function Navigation() {
const navigate = useNavigate()
useKeyCommands({
'g+d': {
name: 'Go to Dashboard',
execute: () => navigate('/dashboard'),
},
'g+s': {
name: 'Go to Settings',
execute: () => navigate('/settings'),
},
'g+p': {
name: 'Go to Profile',
execute: () => navigate('/profile'),
},
}, { ignoreInputs: true })
return null // This component just registers shortcuts
}Stability warning: The commands object is used in a useEffect dependency array. If you define it inline, it creates a new object every render and re-registers all bindings. Either define it outside the component, or wrap it in useMemo.
// Good: stable reference
const commands = useMemo(() => ({
'g+d': { name: 'Dashboard', execute: () => navigate('/dashboard') },
}), [navigate])
useKeyCommands(commands)// Good: stable reference
const commands = useMemo(() => ({
'g+d': { name: 'Dashboard', execute: () => navigate('/dashboard') },
}), [navigate])
useKeyCommands(commands)useKeySequence: Multi-step sequences
For explicit multi-step sequences where each step can be a full key combination:
import { useKeySequence } from '@gentleduck/vim/react'
function Editor() {
// Press Ctrl+K, then Ctrl+C to comment
useKeySequence(['ctrl+k', 'ctrl+c'], () => {
commentSelection()
})
// Press Ctrl+K, then Ctrl+U to uncomment
useKeySequence(['ctrl+k', 'ctrl+u'], () => {
uncommentSelection()
})
}import { useKeySequence } from '@gentleduck/vim/react'
function Editor() {
// Press Ctrl+K, then Ctrl+C to comment
useKeySequence(['ctrl+k', 'ctrl+c'], () => {
commentSelection()
})
// Press Ctrl+K, then Ctrl+U to uncomment
useKeySequence(['ctrl+k', 'ctrl+u'], () => {
uncommentSelection()
})
}This is different from useKeyCommands with 'g+d':
useKeyCommandswith'g+d'uses the Registry's sequence support (single-character steps only).useKeySequenceuses theSequenceManagerand supports full key combinations at each step.
Working without a provider
All hooks work without a KeyProvider. When used outside a provider, they create standalone registries and handlers internally:
// This works even without KeyProvider
function StandaloneComponent() {
useKeyBind('escape', () => close())
return <div>...</div>
}// This works even without KeyProvider
function StandaloneComponent() {
useKeyBind('escape', () => close())
return <div>...</div>
}The tradeoff is that standalone bindings:
- Don't appear in
registry.getAllCommands()(they have their own registry). - Don't participate in sequence prefix matching with other bindings.
- Create extra event listeners.
Use a provider for any non-trivial app.
Accessing the context directly
For advanced use cases, access the raw context:
import { useContext } from 'react'
import { KeyContext } from '@gentleduck/vim/react'
function DebugPanel() {
const ctx = useContext(KeyContext)
if (!ctx) return <p>No KeyProvider found</p>
const commands = Array.from(ctx.registry.getAllCommands())
return (
<div>
<h3>Registered shortcuts ({commands.length})</h3>
<ul>
{commands.map(([binding, cmd]) => (
<li key={binding}>{cmd.name}: {binding}</li>
))}
</ul>
</div>
)
}import { useContext } from 'react'
import { KeyContext } from '@gentleduck/vim/react'
function DebugPanel() {
const ctx = useContext(KeyContext)
if (!ctx) return <p>No KeyProvider found</p>
const commands = Array.from(ctx.registry.getAllCommands())
return (
<div>
<h3>Registered shortcuts ({commands.length})</h3>
<ul>
{commands.map(([binding, cmd]) => (
<li key={binding}>{cmd.name}: {binding}</li>
))}
</ul>
</div>
)
}Putting it together
import { KeyProvider, useKeyBind, useKeyCommands } from '@gentleduck/vim/react'
import { useState } from 'react'
function App() {
const [count, setCount] = useState(0)
const [paletteOpen, setPaletteOpen] = useState(false)
useKeyBind('Mod+k', () => setPaletteOpen(true), { preventDefault: true })
useKeyBind('escape', () => setPaletteOpen(false), { enabled: paletteOpen })
useKeyCommands({
'g+i': {
name: 'Increment',
execute: () => setCount((c) => c + 1),
},
'g+r': {
name: 'Reset',
execute: () => setCount(0),
},
}, { ignoreInputs: true })
return (
<div className="p-8">
<p>Count: {count}</p>
<p className="text-sm text-gray-500">
<Kbd>Ctrl/Cmd+K</Kbd>: palette | <Kbd>G</Kbd>,<Kbd>I</Kbd>: increment | <Kbd>G</Kbd>,<Kbd>R</Kbd>: reset | <Kbd>Escape</Kbd>: close palette
</p>
{paletteOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white p-8 rounded">Command Palette</div>
</div>
)}
</div>
)
}
export default function Root() {
return (
<KeyProvider debug>
<App />
</KeyProvider>
)
}import { KeyProvider, useKeyBind, useKeyCommands } from '@gentleduck/vim/react'
import { useState } from 'react'
function App() {
const [count, setCount] = useState(0)
const [paletteOpen, setPaletteOpen] = useState(false)
useKeyBind('Mod+k', () => setPaletteOpen(true), { preventDefault: true })
useKeyBind('escape', () => setPaletteOpen(false), { enabled: paletteOpen })
useKeyCommands({
'g+i': {
name: 'Increment',
execute: () => setCount((c) => c + 1),
},
'g+r': {
name: 'Reset',
execute: () => setCount(0),
},
}, { ignoreInputs: true })
return (
<div className="p-8">
<p>Count: {count}</p>
<p className="text-sm text-gray-500">
<Kbd>Ctrl/Cmd+K</Kbd>: palette | <Kbd>G</Kbd>,<Kbd>I</Kbd>: increment | <Kbd>G</Kbd>,<Kbd>R</Kbd>: reset | <Kbd>Escape</Kbd>: close palette
</p>
{paletteOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white p-8 rounded">Command Palette</div>
</div>
)}
</div>
)
}
export default function Root() {
return (
<KeyProvider debug>
<App />
</KeyProvider>
)
}Exercises
- Create a
KeyProviderand register three shortcuts withuseKeyCommands. - Add a
useKeyBindfor Escape that only fires when a modal is open. - Try removing the
KeyProviderand confirm thatuseKeyBindstill works standalone.