use-undo-redov1.1.0 · @anandarizki

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.

$ npm i @anandarizki/use-undo-redo
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

This board is one useStateWhite to move
87654321
abcdefgh
pointer
0
canUndo
false
canRedo
false
history
0/30
history · click to jumpTo()
  1. 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-redo

Using 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:

Before
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.

After
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. 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. 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. 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. 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.

  5. 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

OptionTypeDefaultWhat it does
capacitynumberdefault 10The 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.
debouncenumber (ms)default 0Waits 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.
cloneboolean | (value: T) => Tdefault trueHow 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].

NameTypeDescription
undo() => voidRevert to the previous entry.
redo() => voidMove forward to the next entry.
canUndobooleanWhether there is an entry to go back to.
canRedobooleanWhether there is an entry to go forward to.
historyStateHistory<T>[]The recorded entries, each { value, timestamp }. Treat it as read-only.
pointernumberIndex of the current entry in history.
jumpTo(index: number) => voidJump to an entry by index. Invalid indexes are ignored.
reset() => voidClear the history and reset the pointer.
Exported types
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, jumpTo and reset keep 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.

0

Disabled buttons

Pass the canUndo and canRedo values to the buttons, allowing you to disable them based on availability.

0

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.

0

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.

Current: empty
History0 / 20