# How Patch-Based Undo/Redo History Works with Mutative in Instatic

> Explore how Instatic's patch-based undo redo history with Mutative efficiently tracks mutations using forward and inverse patches for deterministic apply operations.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-07-28

---

**Instatic leverages Mutative to capture forward and inverse patches for every site mutation, storing them in a bounded, coalesced stack that enables deterministic undo/redo via `apply()` without cloning the entire document.**

The Instatic visual editor maintains a complete editing history without the memory overhead of full state snapshots. By integrating the Mutative library for immutable updates, the application records only the granular patches needed to reverse or replay changes, achieving O(change) performance regardless of site size.

## Capturing Mutations with Mutative

All undoable editor actions flow through the helper functions defined in [[`helpers.ts`](https://github.com/CoreBunch/Instatic/blob/main/helpers.ts)](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts). The core mechanism resides in `runHistoricMutation`, which creates a Mutative draft with patches enabled:

```ts
// From helpers.ts, lines 60-71
import { create, apply } from 'mutative';

function runHistoricMutation(recipe, options) {
  const [next, patches, inverse] = create(
    currentState,
    recipe,
    { enablePatches: true }
  );
  // patches = forward changes, inverse = backward changes
  return { next, patches, inverse };
}

```

When a mutation executes, Mutative returns three critical pieces of data:
- **`next`** – the mutated draft state
- **`patches`** – forward patches describing what changed
- **`inverse`** – inverse patches describing how to revert those changes

## Scoping and Filtering Patches

Not every state change belongs in the history. The system filters patches to include only those affecting the `'site'` property of the store, ensuring UI state and selection metadata remain outside the undo stack. As implemented in [[`helpers.ts`](https://github.com/CoreBunch/Instatic/blob/main/helpers.ts)](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts) (lines 77-84), the leading `'site'` path segment is stripped so that `apply(site, …)` operates directly on the site object rather than the full store wrapper.

## Managing the History Stack

### Committing History Entries

The `commitHistory` function (lines 15-36 of [[`helpers.ts`](https://github.com/CoreBunch/Instatic/blob/main/helpers.ts)](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts)) constructs a `HistoryEntry` object and pushes it onto the `_historyPast` array:

```ts
interface HistoryEntry {
  inverse: Patch[];
  forward: Patch[];
  coalesceKey?: string;
}

// MAX_HISTORY is imported from defaults.ts
if (_historyPast.length >= MAX_HISTORY) {
  _historyPast.shift(); // Evict oldest entry
}
_historyPast.push(entry);

```

### Coalescing Rapid Edits

To prevent every keystroke from generating a separate undo step, Instatic implements temporal coalescing via `coalesceKey`. When consecutive mutations share the same key (typically a burst identifier for typing sessions), `foldIntoCoalescedEntry` (lines 34-58) merges the new patches into the most recent history entry rather than appending a new one. This deduplication ensures that an entire paragraph of typing collapses into a single undo action.

## Executing Undo and Redo Operations

The undo and redo logic lives in [[`undoRedoActions.ts`](https://github.com/CoreBunch/Instatic/blob/main/undoRedoActions.ts)](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/undoRedoActions.ts). These actions manipulate the history stacks and apply patches to the site state.

### Performing Undo

When `undo()` is called (lines 26-28), the system:
1. Pops the last entry from `_historyPast`
2. Applies the inverse patches via `apply(site, entry.inverse)`
3. Pushes the entry onto `_historyFuture` for potential redo
4. Updates derived state (`canUndo`, `canRedo`, `hasUnsavedChanges`)

### Performing Redo

The `redo()` function (lines 58-60) reverses the process:
1. Pops from `_historyFuture`
2. Applies forward patches via `apply(site, entry.forward)`
3. Returns the entry to `_historyPast`

Both operations also trigger `pruneCanvasSelectionDraft` to clear any in-progress selection state that might conflict with the restored history.

## Type Definitions and Data Structures

The shape of the history entry is formally declared in [[`types.ts`](https://github.com/CoreBunch/Instatic/blob/main/types.ts)](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/types.ts) (lines 116-124):

```ts
export interface HistoryEntry {
  inverse: Patch[];
  forward: Patch[];
  coalesceKey?: string;
}

```

This type supports the patch-pair architecture while providing the optional metadata required for coalescing strategies.

## Practical Usage Examples

### Accessing Undo from Components

The `useUndo` hook is exported from the main store entry point at [[`store.ts`](https://github.com/CoreBunch/Instatic/blob/main/store.ts)](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/store.ts):

```tsx
import { useUndo } from '@site/store';

function Toolbar() {
  const undo = useUndo();
  return (
    <button onClick={undo} disabled={!canUndo}>
      Undo
    </button>
  );
}

```

### Recording a Simple Mutation

Use `mutateSite` to wrap changes that should appear in history:

```ts
import { useSiteHelpers } from '@site/store';

function addPage() {
  const { mutateSite } = useSiteHelpers();
  
  mutateSite((site) => {
    const page = createPage(site, 'New Page', '/new');
    site.pages.push(page);
    return true; // Return truthy to signal a change occurred
  });
}

```

### Coalescing Typing Bursts

Pass a `coalesceKey` to group rapid edits into a single undo step:

```ts
function updateText(nodeId: string, text: string, burstId: string) {
  const { mutateActiveTree } = useSiteHelpers();
  
  mutateActiveTree(
    (tree) => {
      const node = tree.nodes[nodeId];
      if (node) node.props.text = text;
    },
    { coalesceKey: burstId } // All edits with this key merge into one entry
  );
}

```

## Summary

- **Patch-based storage** – Instatic uses Mutative's `enablePatches` option to capture forward and inverse patches instead of cloning entire state trees.
- **Scoped recording** – Only mutations under the `'site'` path are recorded; UI state mutations are applied live but excluded from history.
- **Bounded memory** – The history stack respects `MAX_HISTORY` from [`defaults.ts`](https://github.com/CoreBunch/Instatic/blob/main/defaults.ts), evicting oldest entries automatically.
- **Temporal coalescing** – The `coalesceKey` mechanism merges consecutive edits sharing the same key, typically used for typing bursts.
- **Deterministic replay** – Undo applies `entry.inverse` via Mutative's `apply()`, while redo applies `entry.forward`, guaranteeing idempotent state restoration.

## Frequently Asked Questions

### How does Instatic avoid storing full state snapshots for every change?

Instead of cloning the entire site document, Instatic records **Mutative patches**—arrays of operations describing exactly which paths changed and how. Each history entry stores only the forward and inverse patch arrays (typically bytes or kilobytes) rather than megabytes of duplicated JSON, keeping memory usage proportional to edit complexity rather than document size.

### What happens when the undo history exceeds the maximum limit?

When `_historyPast` reaches `MAX_HISTORY` (defined in [`defaults.ts`](https://github.com/CoreBunch/Instatic/blob/main/defaults.ts)), the `commitHistory` function shifts the oldest entry off the stack before pushing the new one. This creates a sliding window of undoable actions; once an action falls off the bottom of the stack, it can no longer be undone, though the current state remains valid.

### How does coalescing work for typing bursts?

The `foldIntoCoalescedEntry` helper (lines 34-58 of [`helpers.ts`](https://github.com/CoreBunch/Instatic/blob/main/helpers.ts)) checks if the incoming mutation's `coalesceKey` matches the key of the most recent history entry. If they match, the new forward and inverse patches are merged into the existing entry, with later patches overwriting earlier ones for the same path. This ensures that typing "Hello" generates one undo entry rather than five separate ones.

### Why are only 'site' scoped patches included in the history?

The editor separates **document state** (the actual site content) from **transient UI state** (selection highlights, panel visibility, scroll positions). By filtering patches to those whose first path segment is `'site'`, Instatic ensures that undo operations affect only the content users expect to revert, while preserving their current tool context and selection.