use-undoable-next
v5.1.3
Published
React hook for undo/redo functionality with customizable mutation behaviors and zero dependencies.
Readme
use-undoable-next
Undo/redo for React, without the hassle.
A React hook that adds undo/redo functionality to useState with a familiar API,
customizable mutation behaviors, and zero dependencies.
Topics: react · react-hook · undo · redo · history · state-management · typescript
Features
| Feature | Description |
|---------|-------------|
| Familiar API | Mirrors useState; drop-in replacement with undo/redo support |
| 4 mutation behaviors | mergePastReversed, mergePast, destroyFuture, keepFuture |
| Per-call overrides | Change behavior on-the-fly for individual setState calls |
| History limit | Cap memory usage with a configurable historyLimit |
| Functional updaters | setState(prev => prev + 1) works just like useState |
| Zero dependencies | No bloat; tiny footprint, ships as ESM + CJS |
| TypeScript native | Full type safety with generic state inference |
Benchmark vs use-undoable
use-undoable-next is a modernized fork of use-undoable
with a smaller footprint and faster reducer logic.
Bundle size
| Metric | use-undoable 5.0.0 | use-undoable-next 5.1.2 | Improvement |
|--------|---------------------|---------------------------|-------------|
| ESM bundle | 6.86 KB | 5.49 KB | 20% smaller |
| CJS bundle | 6.88 KB | 6.50 KB | 5% smaller |
| npm tarball | 14.96 KB | 11.89 KB | 21% smaller |
| Unpacked size | 66.59 KB (13 files) | ~28 KB (9 files) | 58% smaller |
| Runtime deps | 0 | 0 | — |
Reducer speed
Benchmark: 50,000 iterations, each performing 50 updates + 25 undos + 25 redos (100 ops/iteration = 5M total ops). Measured on Node 26, Windows.
| Package | Total time | Per op | Ops/sec | Relative |
|---------|-----------|--------|---------|----------|
| use-undoable 5.0.0 | ~1,447 ms | 0.029 ms | ~34,700 | 1.00x (baseline) |
| use-undoable-next 5.1.2 | ~781 ms | 0.016 ms | ~64,300 | 1.85x faster |
The speedup comes from leaner reducer logic (arrow functions, modern spread syntax, fewer intermediate variables) and a streamlined
mutatepath.
Migrating from use-undoable
The API is 100% compatible — only the package name changes. No code logic changes required.
Imports
- import useUndoable from "use-undoable";
+ import useUndoable from "use-undoable-next";Type imports (if used)
- import type { Options, MutationBehavior } from "use-undoable";
+ import type { Options, MutationBehavior } from "use-undoable-next";Everything else stays the same
The hook signature, return tuple, options, behaviors, and helpers are identical:
// ✅ No changes needed — this works as-is
const [state, setState, { undo, redo, canUndo, canRedo, reset, past, future }] =
useUndoable(initialState, {
behavior: "mergePastReversed",
historyLimit: 100,
ignoreIdenticalMutations: true,
});Installation
npm install use-undoable-nextyarn add use-undoable-nextpnpm add use-undoable-nextQuick Start
import useUndoable from "use-undoable-next"
function Counter() {
const [count, setCount, { undo, redo, canUndo, canRedo }] = useUndoable(0)
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={() => setCount(c => c - 1)}>−</button>
<button onClick={undo} disabled={!canUndo}>↶ Undo</button>
<button onClick={redo} disabled={!canRedo}>↷ Redo</button>
</div>
)
}How It Works
useUndoable maintains three pieces of state internally: a past stack, the
present value, and a future stack. Every setState pushes the current
value into past. Calling undo moves the present into future and pops the
last item from past into present. redo does the reverse.
---
title: Undo/Redo State Flow
---
stateDiagram-v2
[*] --> Idle
Idle --> SetState: setState(value)
SetState --> Idle: push present → past\nset present = value\nclear future (per behavior)
Idle --> Undo: undo()
Undo --> Idle: pop past → present\npush old present → future
Idle --> Redo: redo()
Redo --> Idle: pop future → present\npush old present → pastVisual walkthrough
Starting state:
{ past: [], present: 0, future: [] }After setState(1) → setState(2):
{ past: [0, 1], present: 2, future: [] }After undo():
{ past: [0], present: 1, future: [2] }After undo() again:
{ past: [], present: 0, future: [1, 2] }After redo():
{ past: [0], present: 1, future: [2] }API Reference
Hook signature
function useUndoable<T>(
initialPresent: T,
options?: Options
): UseUndoable<T>Return value
The hook returns a tuple [state, setState, helpers]:
| Element | Type | Description |
|---------|------|-------------|
| state | T | The current (present) state value |
| setState | (value \| updater, behavior?, ignoreAction?) => void | Updater; accepts a value or functional updater, like useState |
| helpers.past | T[] | Array of past state values |
| helpers.future | T[] | Array of future (redo-able) state values |
| helpers.undo | () => void | Move one step back in history |
| helpers.redo | () => void | Move one step forward in history |
| helpers.canUndo | boolean | Whether undo is available |
| helpers.canRedo | boolean | Whether redo is available |
| helpers.reset | (newState?) => void | Wipe history and reset present (defaults to initialState) |
| helpers.resetInitialState | (newInitial: T) => void | Replace the first item in past; useful for async data |
| helpers.static_setState | (value, behavior?, ignoreAction?) => void | Stable setter that never changes identity (value only, no functional updater) |
setState parameters
setState(
payload: T | ((prev: T) => T), // new value or functional updater
behavior?: MutationBehavior, // override for this call only
ignoreAction?: boolean // if true, skip history tracking
)Tip: To use the global behavior but set
ignoreAction, passnullas the second argument:setState(value, null, true)
Options
interface Options {
behavior?: MutationBehavior
historyLimit?: number | "infinium" | "infinity"
ignoreIdenticalMutations?: boolean
cloneState?: boolean
}| Option | Default | Description |
|--------|---------|-------------|
| behavior | "mergePastReversed" | How history is treated after a state change following an undo |
| historyLimit | 100 | Max items in the past array. Use "infinium" or "infinity" for unlimited |
| ignoreIdenticalMutations | true | Skip recording a state change if the new value equals the current value |
| cloneState | false | Return a shallow clone instead of the same reference (helps trigger re-renders) |
Mutation Behaviors
The behavior determines what happens to the future array when you make a
new state change after having called undo. This is the killer feature:
you're not locked into one strategy.
Starting point for all examples:
{ past: [], present: 0, future: [1, 2] }Now we call setState(5):
destroyFuture: discard the future
flowchart LR
A["past: []<br/>present: 0<br/>future: [1, 2]"] -->|setState 5| B["past: [0]<br/>present: 5<br/>future: []"]
style B fill:#ffcccc,stroke:#cc0000The future is erased. Values 1 and 2 are gone; the user can't get back to them.
mergePastReversed (default): merge future into past (reversed)
flowchart LR
A["past: []<br/>present: 0<br/>future: [1, 2]"] -->|setState 5| B["past: [0, 2, 1]<br/>present: 5<br/>future: []"]
style B fill:#ccffcc,stroke:#00aa00The future is reversed and appended to the past. Every state remains reachable via undo.
mergePast: merge future into past (as-is)
flowchart LR
A["past: []<br/>present: 0<br/>future: [1, 2]"] -->|setState 5| B["past: [0, 1, 2]<br/>present: 5<br/>future: []"]
style B fill:#ccffcc,stroke:#00aa00Same as above, but the future is appended in its original order.
keepFuture: leave the future untouched
flowchart LR
A["past: []<br/>present: 0<br/>future: [1, 2]"] -->|setState 5| B["past: [0]<br/>present: 5<br/>future: [1, 2]"]
style B fill:#cce5ff,stroke:#0066ccThe future stays in place. Both undo and redo remain available.
Behavior comparison
| Behavior | Future after setState | All states reachable? | Use case |
|----------|------------------------|----------------------|----------|
| destroyFuture | [] | No | Git-style (branch is discarded) |
| mergePastReversed | [] | Yes | Intuitive undo-then-edit (default) |
| mergePast | [] | Yes | Same, but preserves future order |
| keepFuture | unchanged | Yes | Non-destructive, keeps redo available |
Handling Async Data (resetInitialState)
When fetching data from an API, your initialState might start as [] or
undefined. Without resetInitialState, users could undo all the way back to
that empty state.
function TodoList() {
const [todos, setTodos, { undo, redo, resetInitialState }] = useUndoable([])
useEffect(() => {
fetchTodos().then(apiTodos => {
setTodos(apiTodos)
resetInitialState(apiTodos) // ← prevents undo back to []
})
}, [])
return /* ... */
}Important: Call
resetInitialStateonly once. Calling it multiple times on an existing state risks overwriting legitimate history entries.
static_setState
The default setState changes identity on every state update because it supports
functional updaters (which close over present). If this causes issues with
memoized children or effect dependencies, use static_setState:
const [count, setCount, { static_setState }] = useUndoable(0)
// static_setState accepts a value only (no functional updater)
// its identity never changes across renders
static_setState(42)Renaming Destructured Values
Since the third element is an object, you can rename anything:
const [
count,
setCount,
{
past: history,
future: upcoming,
undo: stepBack,
redo: stepForward,
canUndo: hasHistory,
reset: wipe,
},
] = useUndoable(0)Performance
Every state change stores the previous state in memory. For large state objects, consider:
- Use
historyLimitto cap the number of stored past states - Store diffs, not snapshots; instead of storing the full array, store
mutation descriptions like
{ index: 5, field: "name", value: "infinium" } - Use
ignoreIdenticalMutationsto avoid polluting history with no-op updates
// Limit history to 50 entries
const [state, setState] = useUndoable(initialState, {
historyLimit: 50,
})Integration with React Flow v10
React Flow v10 separates node and edge state. To integrate with useUndoable,
make it the exclusive state manager for both; don't let React Flow's
internal state fight with the hook.
Note: Dragging a node fires many
onDragevents, each counted as a separate history entry. You may want to debounce or batch these.
See react-flow-example/src/App.jsx for a
working integration.
Contributing
# Clone and install
git clone https://github.com/SujalXplores/use-undoable-next.git
cd use-undoable-next
pnpm install
# Build the library (runs automatically on install via prepare)
pnpm build
# Run the demo
cd demo && pnpm install && pnpm start