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

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

// 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. 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:

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

  • 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, 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—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:

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

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

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

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

// 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 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, 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. 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →