← Dashboard
Zubeyr··Componentshooksstate

Use 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

example.tsx
'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-history.ts
'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,
  }
}