@usefy/use-history-state
v0.25.1
Published
A React hook for undo/redo state history with time-travel
Maintainers
Readme
Overview
@usefy/use-history-state manages state with a built-in undo/redo timeline. Every set records a new entry, undo/redo step through the history, and goTo jumps to any point — perfect for editors, canvases, forms, and anything a user expects Ctrl/Cmd+Z to work in.
Part of the @usefy ecosystem — a collection of production-ready React hooks designed for modern applications.
Why use-history-state?
- Zero Dependencies — Pure React implementation
- TypeScript First — Full
<T>generics with exported types - Time Travel —
undo,redo, andgoToover an immutable timeline useState-styleset— Pass a value or an updater function (prev => next)- Ready-made Flags —
canUndo/canRedofor disabling toolbar buttons - Inspectable — The full
historyarray pluscurrentIndex - Bounded Memory — Optional
limitdrops the oldest entries automatically - Stable Controls — Every function keeps its identity, so they're safe as
useEffectdependencies - No Wasted Renders — Setting the current value again records nothing
Installation
# npm
npm install @usefy/use-history-state
# yarn
yarn add @usefy/use-history-state
# pnpm
pnpm add @usefy/use-history-statePeer Dependencies
This package requires React 18 or 19:
{
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
}Quick Start
import { useHistoryState } from "@usefy/use-history-state";
function Editor() {
const { state, set, undo, redo, canUndo, canRedo } = useHistoryState(
initialCanvas,
{ limit: 50 }
);
return (
<div>
<Canvas data={state} onChange={set} />
<button onClick={undo} disabled={!canUndo}>
⟲ Undo
</button>
<button onClick={redo} disabled={!canRedo}>
⟳ Redo
</button>
</div>
);
}API Reference
useHistoryState<T>(initialState, options?)
Returns the current state plus a stable set of controls for navigating and mutating the undo/redo timeline.
Parameters
| Parameter | Type | Default | Description |
| -------------- | ----------------------------- | ------- | ------------------------------------------------------------------ |
| initialState | T \| (() => T) | — | Initial state, or a factory returning it (evaluated once on mount) |
| options | UseHistoryStateOptions | {} | Configuration (see below) |
Options
| Option | Type | Default | Description |
| ------- | -------- | ----------- | -------------------------------------------------------------------------------------------- |
| limit | number | unlimited | Max entries to keep. When exceeded, oldest entries drop off the front. Values < 1 = unlimited |
Returns
| Field | Type | Description |
| -------------- | ------------------------------- | --------------------------------------------------------------------------- |
| state | T | The current (present) state |
| set | (next: T \| (prev: T) => T) => void | Record a new state (value or updater). Discards any redoable "future" entries |
| undo | () => void | Step back one entry. No-op when canUndo is false |
| redo | () => void | Step forward one entry. No-op when canRedo is false |
| goTo | (index: number) => void | Jump to an entry (index clamped to valid range) |
| canUndo | boolean | Whether there is a previous state |
| canRedo | boolean | Whether there is a next state |
| clear | () => void | Collapse the timeline down to just the current state |
| reset | () => void | Restore the initial state and wipe the timeline |
| history | readonly T[] | The full timeline, oldest first |
| currentIndex | number | Index of the current state within history |
Updater vs. value: like
useState, ifTis itself a function type, pass an updater (set(() => myFn)) — a bare function argument is always treated asprev => next.
Examples
Counter with undo/redo
const { state, set, undo, redo, canUndo, canRedo } = useHistoryState(0);
set((c) => c + 1); // functional update
undo(); // back to the previous value
redo(); // forward againKeyboard shortcuts (Ctrl/Cmd+Z)
const { undo, redo, canUndo, canRedo } = useHistoryState(initialState);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
e.preventDefault();
if (e.shiftKey) {
if (canRedo) redo();
} else if (canUndo) {
undo();
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [undo, redo, canUndo, canRedo]); // controls are stable — effect stays cheapTime-travel history panel
const { state, set, history, currentIndex, goTo } = useHistoryState(initialDoc);
return (
<aside>
<h3>History</h3>
<ul>
{history.map((entry, i) => (
<li
key={i}
aria-current={i === currentIndex}
onClick={() => goTo(i)}
>
Step {i}
</li>
))}
</ul>
</aside>
);Bounded history for a long-running editor
// Keep only the last 100 states in memory.
const { state, set } = useHistoryState(initialState, { limit: 100 });TypeScript
import {
useHistoryState,
type HistoryStateInitializer,
type HistoryStateUpdater,
type UseHistoryStateOptions,
type UseHistoryStateReturn,
} from "@usefy/use-history-state";
const history: UseHistoryStateReturn<number> = useHistoryState<number>(0);Behavior Notes
- Immutable timeline — Each entry is the value you passed to
set; navigating never mutates entries.undo/redo/goToreuse the samehistoryarray reference (no reallocation). - Branching on
set— Callingsetafter anundodiscards the redoable "future" and starts a new branch from the current point. - No-op skipping — Setting a value equal to the present one (by
Object.is) records nothing and skips the re-render.undoat the start,redoat the end,goToto the current index, andclearon a single-entry timeline are all no-ops. limit— Applies as new entries are added; the oldest entries drop off the front andcurrentIndexshifts to stay valid. Values below1(or non-finite) mean unlimited.- Stable controls — Every returned function keeps the same identity for the component's lifetime, so they're safe as effect dependencies.
- SSR-safe — No
window/documentaccess; renders identically on the server.
Testing
This package maintains comprehensive test coverage to ensure reliability and stability.
Test Coverage
📊 View Detailed Coverage Report (GitHub Pages)
Test Files
useHistoryState.test.ts— 23 tests for history navigation, branching, limits, and stability
Total: 23 tests
License
MIT © mirunamu
This package is part of the usefy monorepo.
