# How the Instatic Editor Store Implements Undo/Redo with Zustand and Mutative

> Discover how Instatic's editor store leverages Zustand and Mutative for robust undo/redo functionality. Explore granular patch recording and efficient history management.

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

---

**The Instatic editor implements undo/redo by wrapping a Zustand store with the zustand-mutative middleware to record granular patches, storing inverse and forward patch pairs in a bounded history stack, and exposing stable React hooks for UI consumption.**

The visual editor in the CoreBunch/Instatic repository manages complex state mutations using a hybrid approach that combines Zustand's lightweight state management with Mutative's patch-based immutability. This architecture enables fine-grained undo/redo functionality that tracks only site-scoped changes while maintaining a single source of truth for all editor state.

## Store Architecture with Zustand and Mutative

### Store Creation and Middleware Setup

The editor store is instantiated in [`src/admin/pages/site/store/store.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/store.ts) using Zustand's `create` function wrapped with two essential middlewares. The `mutative` middleware replaces Immer and enables patch recording, while `subscribeWithSelector` provides performant selector subscriptions.

```typescript
// From src/admin/pages/site/store/store.ts
// Patch-based undo history opts INTO patches per-call via mutative `create`.

```

The store's type (`EditorStore`) aggregates multiple slices covering site data, UI state, selection, and clipboard. Each slice defines its state fields and actions that receive a **Mutative draft** via the standard Zustand `set` function pattern: `set(state => { ... })`.

### Slice-Based State Management

State mutations that require undo support flow through the `mutateSite` helper defined in [`src/admin/pages/site/store/slices/site/helpers.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts). This centralized mutation gateway ensures that any change to the site structure automatically generates the patch records needed for history management.

## Patch-Based Undo/Redo Mechanism

### Recording Mutative Patches

When an undoable mutation executes, `mutateSite` captures **Mutative patches** containing both an **inverse** part (to undo) and a **forward** part (to redo). As documented in [`src/admin/pages/site/store/slices/site/undoRedoActions.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/undoRedoActions.ts):

> "carries `inverse` patches (applied on undo) and `forward` patches (applied on redo) patches scoped to..."

The `mutateSite` function implements this in [`src/admin/pages/site/store/slices/site/helpers.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts):

- Line 239: "Runs `recipe` against a Mutative draft ... records ONLY the `site`-scoped patches in undo history."
- Line 207: "Commit one transaction's site-scoped patch pair to undo history."

A history entry is only committed when the draft actually changes, preventing empty transactions from polluting the stack. The history size is bounded by `MAX_UNDO_HISTORY`, defined in [`src/admin/pages/site/store/slices/site/defaults.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/defaults.ts), ensuring memory usage remains constant.

### The Undo and Redo Actions

The `UndoRedoActions` type—defined as `Pick<SiteSlice, 'undo' | 'redo'>` in [`src/admin/pages/site/store/slices/site/undoRedoActions.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/undoRedoActions.ts)—exposes two core actions:

- **`undo`**: Pops the latest history entry, applies its `inverse` patches to revert state, and pushes the entry onto the redo stack.
- **`redo`**: Pops from the redo stack, applies the `forward` patches, and returns the entry to the undo stack.

These actions are exposed via stable React hooks in [`src/admin/pages/site/store/store.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/store.ts):

- Line 270: `export const useUndo = () => useEditorStore(s => s.undo)`
- Line 275: `export const useCanUndo = () => useEditorStore(s => s.canUndo)`

Similar hooks exist for `useRedo` and `useCanRedo`, providing components with memoized callbacks without requiring manual memoization.

### Coalescing High-Frequency Edits

For high-frequency operations like typing, the editor groups changes into single undo steps using a **coalesce key**. When a new mutation begins with a different key, the previous entry closes, preventing a flood of tiny history entries. This behavior is documented in [`src/admin/pages/site/store/slices/site/undoRedoActions.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/undoRedoActions.ts):

> "The whole typing burst is ONE undo step ... start a fresh undo entry rather than folding into the undone one."

Multi-node operations such as cutting or pasting also leverage this system. The clipboard slice in [`src/admin/pages/site/store/slices/clipboardSlice.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/clipboardSlice.ts) composes complex operations into single undo entries by delegating to underlying actions that already record patches:

> "Multi-cut: copy then delete every id in one undo step."

## Implementing Undo/Redo in React Components

Consuming the undo/redo functionality from React components requires importing the custom hooks from the store file.

**Basic Undo Button:**

```tsx
// React component – Undo button
import { useUndo, useCanUndo } from '@admin/pages/site/store/store';

export function UndoButton() {
  const undo = useUndo();
  const canUndo = useCanUndo();

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

```

**Performing Undoable Mutations:**

```ts
// Site mutation – adding a page (undoable)
import { useEditorStore } from '@admin/pages/site/store/store';
import { createPage } from '@admin/pages/site/store/slices/site/pageActions';

export function addHomePage() {
  const mutateSite = useEditorStore.getState().mutateSite;
  mutateSite((draft) => {
    createPage(draft, {
      id: 'home',
      title: 'Home',
      route: '/',
    });
  });
  // The mutation automatically records an undo entry.
}

```

**Batch Operations:**

```ts
// Custom batch operation – deleting several nodes in one undo step
import { useEditorStore } from '@admin/pages/site/store/store';
import { deleteNodes } from '@admin/pages/site/store/slices/site/nodeActions';

export function deleteSelection(ids: string[]) {
  const mutateSite = useEditorStore.getState().mutateSite;
  mutateSite((draft) => {
    deleteNodes(draft, ids);
  });
  // One undo entry for the entire batch.
}

```

## Summary

- **Single source of truth**: All editor state (site documents, UI state, selection, etc.) lives in one Zustand store enhanced with `subscribeWithSelector`.
- **Patch-based history**: The `mutative` middleware generates granular patches; the store records only site-scoped changes via `mutateSite` to minimize memory overhead.
- **Bounded stack**: `MAX_UNDO_HISTORY` in [`defaults.ts`](https://github.com/CoreBunch/Instatic/blob/main/defaults.ts) prevents unbounded memory growth.
- **Stable hooks**: `useUndo`, `useRedo`, `useCanUndo`, and `useCanRedo` provide components with direct access to actions without manual memoization.
- **Coalesced edits**: High-frequency changes like typing are grouped into single undo steps using coalesce keys.

## Frequently Asked Questions

### How does the Instatic editor store handle undo/redo with Zustand?

The store wraps Zustand with the `zustand-mutative` middleware to capture granular patches for every mutation. When `mutateSite` executes a recipe, it records the inverse and forward patches for that transaction, pushing them onto a bounded history stack. The `undo` and `redo` actions then apply these patches to revert or reapply state changes.

### What is the difference between inverse and forward patches in Mutative?

**Inverse patches** contain the operations needed to reverse a specific mutation, while **forward patches** contain the operations needed to reapply it. When `mutateSite` records a transaction in [`src/admin/pages/site/store/slices/site/helpers.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/helpers.ts), it stores both patch types. The `undo` action applies the inverse patches to revert state, and the `redo` action applies the forward patches to restore it.

### How does the store prevent memory leaks with undo history?

The undo history is bounded by the `MAX_UNDO_HISTORY` constant defined in [`src/admin/pages/site/store/slices/site/defaults.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/defaults.ts). This limits the stack to a fixed maximum number of entries, ensuring that long editing sessions do not consume unbounded memory. Additionally, the store only commits patches when the draft actually changes, avoiding empty entries.

### Can multiple operations be grouped into a single undo step?

Yes. The editor uses a **coalesce key** system to group high-frequency edits (such as typing bursts) into single undo entries. When the coalesce key changes, `mutateSite` closes the previous history entry and starts a new one. Batch operations like multi-node deletion also execute within a single `mutateSite` call, producing one unified undo entry.