Zubeyr··ComponentshooksstateUse History
An undo/redo hook for any state. Consecutive edits to the same field merge into one step, and Ctrl+Z / Ctrl+Shift+Z hotkeys are opt-in so typing in inputs keeps its native undo.
Live demo ↗Installation
Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.
pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/use-history.json- →Files land in
components/dashboard.
Usage
'use client'
import { Redo2, Undo2 } from 'lucide-react'
import { Button } from '@/registry/dashboard/components/button/button'
import { Field, Input } from '@/registry/dashboard/components/input/input'
import { useHistory } from '@/components/dashboard/use-history'
export default function Demo() {
const { value, set, undo, redo, canUndo, canRedo, reset } = useHistory(
{ name: 'Jordan Ellis', company: 'Northwind' },
{ hotkeys: true },
)
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<Field label="Name" htmlFor="name">
<Input
id="name"
value={value.name}
onChange={(e) => set({ ...value, name: e.target.value }, { merge: 'name' })}
/>
</Field>
<Field label="Company" htmlFor="company">
<Input
id="company"
value={value.company}
onChange={(e) => set({ ...value, company: e.target.value }, { merge: 'company' })}
/>
</Field>
<div className="flex gap-2">
<Button size="icon" aria-label="Undo" disabled={!canUndo} onClick={undo}>
<Undo2 />
</Button>
<Button size="icon" aria-label="Redo" disabled={!canRedo} onClick={redo}>
<Redo2 />
</Button>
<Button variant="ghost" onClick={() => reset()}>
Clear history
</Button>
</div>
<p className="text-xs text-muted-foreground">
Typing into one field is a single undo step. Ctrl+Z works when focus is outside the inputs.
</p>
</div>
)
}Source code
'use client'
import { useCallback, useEffect, useState } from 'react'
type State<T> = { past: T[]; present: T; future: T[]; mergeKey: string | null }
type SetOptions = { merge?: string }
export function useHistory<T>(initial: T, { hotkeys = false }: { hotkeys?: boolean } = {}) {
const [state, setState] = useState<State<T>>({
past: [],
present: initial,
future: [],
mergeKey: null,
})
const set = useCallback((next: T | ((current: T) => T), { merge }: SetOptions = {}) => {
setState((s) => {
const value = typeof next === 'function' ? (next as (current: T) => T)(s.present) : next
const merging = merge !== undefined && merge === s.mergeKey
return {
past: merging ? s.past : [...s.past, s.present],
present: value,
future: [],
mergeKey: merge ?? null,
}
})
}, [])
const undo = useCallback(() => {
setState((s) =>
s.past.length
? {
past: s.past.slice(0, -1),
present: s.past[s.past.length - 1],
future: [s.present, ...s.future],
mergeKey: null,
}
: s,
)
}, [])
const redo = useCallback(() => {
setState((s) =>
s.future.length
? {
past: [...s.past, s.present],
present: s.future[0],
future: s.future.slice(1),
mergeKey: null,
}
: s,
)
}, [])
const reset = useCallback(
(value?: T) =>
setState((s) => ({ past: [], present: value ?? s.present, future: [], mergeKey: null })),
[],
)
useEffect(() => {
if (!hotkeys) return
const onKey = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return
const target = event.target as HTMLElement
if (target.closest('input, textarea, [contenteditable="true"]')) return
event.preventDefault()
if (event.shiftKey) redo()
else undo()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [hotkeys, undo, redo])
return {
value: state.present,
set,
undo,
redo,
reset,
canUndo: state.past.length > 0,
canRedo: state.future.length > 0,
}
}