StunkStunk
Middleware

withHistory

Add undo and redo to any chunk with built-in history tracking.

withHistory() wraps an existing chunk and adds a full undo/redo history stack to it. Every set() call is recorded — you can travel backwards and forwards through the state timeline.

import { chunk } from "stunk";
import { withHistory } from "stunk/middleware";

const count = chunk(0);
const countWithHistory = withHistory(count);

countWithHistory.set(1);
countWithHistory.set(2);
countWithHistory.set(3);

countWithHistory.undo(); // 2
countWithHistory.undo(); // 1
countWithHistory.redo(); // 2

API reference

undo()

Reverts to the previous value in history. Does nothing if there's no previous state.

countWithHistory.undo();

redo()

Moves forward to the next value in history. Does nothing if there's no next state.

countWithHistory.redo();

canUndo()

Returns true if there is a previous state to revert to.

if (countWithHistory.canUndo()) {
  countWithHistory.undo();
}

canRedo()

Returns true if there is a next state to move forward to.

if (countWithHistory.canRedo()) {
  countWithHistory.redo();
}

getHistory()

Returns a copy of all recorded values in order, oldest first.

countWithHistory.set(10);
countWithHistory.set(20);

countWithHistory.getHistory(); // [0, 10, 20]

clearHistory()

Wipes the history stack, keeping only the current value as the new baseline.

countWithHistory.clearHistory();
countWithHistory.getHistory(); // [currentValue]
countWithHistory.canUndo(); // false

Options

OptionTypeDefaultDescription
maxHistorynumber100Maximum number of history entries to keep. Oldest entries are removed when the limit is reached.
const countWithHistory = withHistory(count, { maxHistory: 50 });

When the history limit is reached, the oldest entries are removed automatically and a warning is logged to the console.


Branching history

When you set() a new value after undoing, all future history is discarded — just like most editors:

countWithHistory.set(1);
countWithHistory.set(2);
countWithHistory.set(3);

countWithHistory.undo(); // back to 2
countWithHistory.set(99); // history is now [0, 1, 2, 99] — "3" is gone

countWithHistory.canRedo(); // false

In React

import { chunk } from "stunk";
import { withHistory } from "stunk/middleware";
import { useChunk } from "stunk/react";

const counter = chunk(0);
const counterWithHistory = withHistory(counter);

function Counter() {
  const [count, setCount] = useChunk(counterWithHistory);

  const canUndo = counterWithHistory.canUndo();
  const canRedo = counterWithHistory.canRedo();

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
      <button onClick={() => counterWithHistory.undo()} disabled={!canUndo}>
        Undo
      </button>
      <button onClick={() => counterWithHistory.redo()} disabled={!canRedo}>
        Redo
      </button>
    </div>
  );
}

Since useChunk subscribes to the chunk and calls setState on every change, the component re-renders whenever the value updates — and canUndo() / canRedo() are re-evaluated as part of that render, keeping the buttons in sync automatically.


What's next?

On this page