Every state change is a move. Take any of them back.
useUndoRedo adds undo and redo to any React state with one line. Keep your useState; the hook keeps the history.
const [game, setGame] = useState(start);
const [undo, redo, {
canUndo, canRedo,
history, pointer, jumpTo,
}] = useUndoRedo([game, setGame], {
capacity: 30,
});That’s the code behind the board: each move is a setGame.
React 18.2+ and React 19 · TypeScript types included · MIT
- pointer
- 0
- canUndo
- false
- canRedo
- false
- history
- 0/30
- Click a white piece, then the square to move it to. Any square works: there are no chess rules, just state.
Installation
Install the package from npm. It needs React 18.2 or later, including React 19.
npm i @anandarizki/use-undo-redoUsing another package manager? pnpm add @anandarizki/use-undo-redo or yarn add @anandarizki/use-undo-redo work the same way.
Usage
Pass the hook the pair that useState gives you. It watches the value, records each change, and hands back undo and redo.
// import the hook
import { useUndoRedo } from "@anandarizki/use-undo-redo";
// your state handler
const [state, setState] = useState();
// pass your state and setState as the argument
const [undo, redo] = useUndoRedo([state, setState]);Integrating into an existing component
You don’t need to change how your component manages state. Start with an ordinary counter:
import { useState } from "react";
function MyComponent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count - 1)}>-</button>
{count}
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}Add one line, then wire undo and redo to buttons. The counter’s own code stays exactly as it was.
import { useState } from "react";
import { useUndoRedo } from "@anandarizki/use-undo-redo";
function MyComponent() {
const [count, setCount] = useState(0);
// the new line
const [undo, redo] = useUndoRedo([count, setCount]);
return (
<div>
<button onClick={() => setCount(count - 1)}>-</button>
{count}
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={undo}>Undo</button>
<button onClick={redo}>Redo</button>
</div>
);
}How history works
The hook keeps a list of entries and a pointer to the one you are on. Four rules cover everything it does; the board at the top of the page follows the same rules.
- 1.
Every change is recorded
setCount(1); setCount(2); setCount(3)The first render stores the starting value. Each change after that is added to history, and pointer follows the newest entry.
- 2.
Undo moves the pointer
undo(); undo()Nothing is deleted. The pointer steps back and your state is set to that entry. Entries ahead of the pointer are what redo can restore.
- 3.
A new change drops the redo branch
setCount(5)Changing state while you are behind the newest entry discards everything ahead of the pointer, like playing a different move after a takeback.
- 4.
Capacity trims the oldest
setCount(6) // with { capacity: 3 }capacity counts every entry, including the starting one, so capacity: 3 allows two undo steps. When history is full, the oldest entry falls off.
- pointer undo-able redo-able dropped
useUndoRedo
useUndoRedo<T>(
primaryState: [T, (v: T) => void],
options?: Options<T>
): Output<T>primaryState is the [value, setValue] pair whose history the hook manages. Everything else is optional. Here is the full form:
const [undo, redo, { canUndo, canRedo, jumpTo, history, pointer, reset }] =
useUndoRedo([state, setState], {
debounce: 500,
capacity: 20,
});Options
| Option | Type | Default | What it does |
|---|---|---|---|
| capacity | number | default 10 | The most entries kept in history, including the starting state, so capacity: 10 allows 9 undo steps. Values below 1 are treated as 1, and Infinity keeps everything. |
| debounce | number (ms) | default 0 | Waits this long after the last change before recording it, so rapid updates such as typing become one entry. Calling undo or jumpTo while an update is pending records it first, so it can be redone. |
| clone | boolean | (value: T) => T | default true | How a value is copied into history. true deep-clones it with lodash cloneDeep. false stores it as-is: faster and lighter, but only safe if you never mutate state in place. A function lets you provide your own copy. |
Returns
An array: [undo, redo, controls].
| Name | Type | Description |
|---|---|---|
| undo | () => void | Revert to the previous entry. |
| redo | () => void | Move forward to the next entry. |
| canUndo | boolean | Whether there is an entry to go back to. |
| canRedo | boolean | Whether there is an entry to go forward to. |
| history | StateHistory<T>[] | The recorded entries, each { value, timestamp }. Treat it as read-only. |
| pointer | number | Index of the current entry in history. |
| jumpTo | (index: number) => void | Jump to an entry by index. Invalid indexes are ignored. |
| reset | () => void | Clear the history and reset the pointer. |
import type { Options, Output, StateHistory } from "@anandarizki/use-undo-redo";
type StateHistory<T> = {
value: T;
timestamp: Date;
};Notes
- Changes are compared deeply with lodash
isEqual. Setting a new object with the same content as the current entry is not recorded as a change. - The functions are stable.
undo,redo,jumpToandresetkeep the same identity across renders, so they are safe to pass to memoized components or list as effect dependencies. - Store plain data. Class instances with private (
#field) properties can’t be compared or cloned by lodash.
Recipes
Keyboard shortcuts
Because undo and redo are stable, you can bind them to Ctrl/⌘ Z once without re-subscribing on every render.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "z") return;
e.preventDefault();
if (e.shiftKey) redo();
else undo();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [undo, redo]);Cloning strategies
The default deep clone is the safe choice. If you always update state immutably, or your values are large, choose how history copies them.
// Immutable updates only: store values as-is
useUndoRedo([todos, setTodos], { clone: false });
// Bring your own copy
useUndoRedo([doc, setDoc], { clone: (value) => structuredClone(value) });
// Keep every change
useUndoRedo([drawing, setDrawing], { capacity: Infinity });Examples
The examples use shadcn/ui components for demonstration only. The hook doesn’t depend on any UI library.
UndoRedo button
This is a generic undo/redo button component that will be used across all examples.
Basic
A counter with the hook added and the shared undo/redo buttons wired to it. Click + a few times, then undo.
Disabled buttons
Pass the canUndo and canRedo values to the buttons, allowing you to disable them based on availability.
Debounce
With debounce: 200, a burst of changes (typing, dragging the slider) becomes one history entry once you pause for 200 ms. Useful for controlled inputs.
History list
Everything the hook returns, used together: type a name, pause, and each settled value appears in history. Click an entry to jumpTo it, or reset to clear the list.
