Skip to main content

Lesson 4: React Integration

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:

PropTypeDefaultDescription
debugbooleanfalseLog key events and matches to console
timeoutMsnumber600Timeout for multi-key sequences
defaultOptionsPartial<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..." />
}

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
}
// 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()
  })
}

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>
}

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

  1. Create a KeyProvider and register three shortcuts with useKeyCommands.
  2. Add a useKeyBind for Escape that only fires when a modal is open.
  3. Try removing the KeyProvider and confirm that useKeyBind still works standalone.