StunkStunk
Middleware

history

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

history() 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 { history } from "stunk/middleware";

const count = chunk(0);
const tracked = history(count);

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

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

API reference

undo()

Reverts to the previous value. Does nothing if at the beginning of history.

tracked.undo();

redo()

Moves forward to the next value. Does nothing if at the end of history.

tracked.redo();

canUndo()

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

if (tracked.canUndo()) tracked.undo();

canRedo()

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

if (tracked.canRedo()) tracked.redo();

getHistory()

Returns a copy of all recorded values, oldest first.

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

clearHistory()

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

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

reset()

Resets the chunk to its initial value and clears the entire history stack. After reset, canUndo() and canRedo() both return false.

tracked.set(1);
tracked.set(2);

tracked.reset();
tracked.get(); // 0 (initial value)
tracked.canUndo(); // false — history cleared
tracked.canRedo(); // false

In v3, reset() is overridden to also clear history. In v2, calling reset() only reset the value but left history intact — which meant canUndo() could return true even after a reset.


Options

OptionTypeDefaultDescription
maxHistorynumber100Maximum history entries. Oldest entries removed when limit is reached
skipDuplicatesboolean | "shallow"falseSkip recording identical values. See below.
const tracked = history(count, { maxHistory: 50 });

skipDuplicates

Control whether consecutive identical values are recorded:

// true — skips strictly equal values (===)
const tracked = history(count, { skipDuplicates: true });

tracked.set(5);
tracked.set(5); // skipped — same primitive value
tracked.getHistory(); // [0, 5]

// 'shallow' — also skips shallowly equal objects
const form = history(formChunk, { skipDuplicates: "shallow" });

form.set({ name: "Fola", age: 25 });
form.set({ name: "Fola", age: 25 }); // skipped — same flat values

skipDuplicates: true uses strict equality (===) — new objects with the same values are not skipped. Use 'shallow' for flat object comparison.


Branching history

set() after an undo() discards all forward history — just like most editors:

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

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

In React

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

const counter = chunk(0);
const tracked = history(counter);

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

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

What's next?

On this page