How Instatic Editor History Uses Mutative for Undo: Implementation Guide

Instatic implements undo functionality by wrapping Mutative drafts in a runHistoricMutation helper that captures patches and pushes them to a per-step history stack integrated with Y.UndoManager, enabling coalesced, fine-grained undo operations.

The CoreBunch/Instatic repository implements a robust editor history system by combining the Mutative library with Zustand middleware. This architecture captures every state change as a set of patches, enabling precise undo and redo operations while integrating seamlessly with Yjs collaborative documents.

Architecture Overview: Mutative and Zustand Integration

The foundation of Instatic's undo system lies in the global editor store defined in src/admin/pages/site/store/store.ts. Lines 59‑63 initialize the Zustand store with the mutative middleware from zustand-mutative, which provides a draft-based mutation API and automatically records Mutative patches for every state change.

This setup allows state slices to mutate drafts directly while the middleware handles immutability behind the scenes. When paired with the history management layer, these mutations generate the patch data required for reversible undo steps.

The Mutation Pipeline: Capturing Patches with runHistoricMutation

All undo-aware state changes flow through runHistoricMutation, a helper defined in src/admin/pages/site/store/slices/site/helpers.ts at lines 164‑170. This function serves as the bridge between Mutative's draft-based API and the editor's history stack.

The helper accepts a recipe function that receives a Draft<EditorStore> and an optional coalesceKey. Internally, it calls mutative.create to execute the recipe against a draft, capturing both the result and the generated patches. These patches represent the precise changes made to the state tree.

// Core helper – runs a mutation and records a single undo step
function runHistoricMutation<T>(
  recipe: (draft: Draft<EditorStore>) => T,
  coalesceKey: string | null = null
): T {
  // `create` from Mutative returns patches + the result of the recipe
  const { result, patches } = create(recipe);
  // Store patches in undo history (via collab binding)
  pushUndoStep(patches, coalesceKey);
  return result;
}

Slices such as site use this wrapper to ensure every mutation automatically registers with the undo system without manual patch management.

Coalescing Rapid Edits

To prevent flooding the history stack with micro-edits (such as individual keystrokes), the coalesceKey parameter enables logical grouping. When multiple mutations share the same coalesceKey, the system merges their patches into a single undo entry. This ensures that a burst of rapid typing collapses into one user-visible undo step rather than dozens.

History Management and Yjs Integration

Once runHistoricMutation generates patches, they are passed to the collaboration binding layer in src/admin/pages/site/store/slices/site/collabBinding.ts (lines 88‑119). Here, the pushUndoStep function stores patches in a per-step undo route map that tracks which Yjs documents (Y.Doc) were affected by each change.

This design separates the patch generation (handled by Mutative) from the document synchronization (handled by Yjs). Lines 310‑326 in the same file manage the cleanup and routing logic, ensuring that undo steps correctly map back to their originating collaborative documents.

Executing Undo and Redo Operations

The actual undo and redo logic resides in src/admin/pages/site/store/slices/site/undoRedoActions.ts at lines 15‑22. When undo() is invoked, it retrieves the stored route of affected documents from the undo stack, then calls Y.UndoManager.undo() for each corresponding Y-Doc to revert the changes.

// Undo action implementation (simplified)
export const undo = () => {
  // Gather docs that changed in the last step
  const docsToUndo = undoRoute.pop() ?? [];
  docsToUndo.forEach((docId) => {
    const manager = managed.get(docId)?.manager;
    manager?.undo();               // Y.UndoManager performs the revert
  });
  // Refresh UI after undo
  updateStoreFromDocs();
};

The redo() function performs the inverse operation using the same route logic, reapplying patches that were previously reverted. This two-step process—Mutative for state drafting and Yjs for operational transformation—ensures consistency across local and collaborative contexts.

React Hook Integration

Components access the undo capability through the useUndo hook exported from src/admin/pages/site/store/store.ts at lines 272‑278. This stable selector provides direct access to the undo action, enabling keyboard shortcuts like CMD+Z to trigger history traversal efficiently.

// Hook used by UI components
export const useUndo = () => useEditorStore((s) => s.undo);

// Example of an undo‑aware mutation (adding a new page)
function addNewPage(name: string) {
  // `mutateSite` is a wrapper around `runHistoricMutation`
  mutateSite((draft) => {
    // `draft.site` is a Mutative draft of the whole site
    draft.site!.pages.push({
      id: generateId(),
      title: name,
      nodes: [],
    });
  });
}

Summary

  • Mutative integration: The Zustand store in store.ts uses mutative middleware to enable draft-based mutations with automatic patch generation.
  • Centralized mutation helper: runHistoricMutation in helpers.ts wraps all state changes, capturing patches and managing coalescing via coalesceKey.
  • Yjs coordination: The collab binding layer routes patches to Y.UndoManager instances, maintaining synchronization between local undo history and collaborative documents.
  • Action execution: undoRedoActions.ts implements the actual traversal of history routes, invoking Y.UndoManager.undo() and redo() on affected documents.
  • React interface: The useUndo hook provides components with direct access to undo functionality without prop drilling.

Frequently Asked Questions

What is Mutative and why does Instatic use it for undo?

Mutative is a JavaScript library that provides immutable updates via draft mutations, similar to Immer but with enhanced performance characteristics. Instatic uses Mutative because it generates granular patches automatically, which the editor history system can capture and store for precise reversibility without requiring developers to manually track diffs.

How does coalescing work in Instatic's undo system?

Coalescing groups multiple rapid mutations into a single undo entry. When calling runHistoricMutation, developers can provide a coalesceKey string. Consecutive mutations sharing the same key have their patches merged in the history stack, ensuring that operations like continuous typing generate one undo step rather than individual character-level steps.

What role does Y.UndoManager play in the Instatic editor?

Y.UndoManager from the Yjs library manages the operational transformation layer for collaborative editing. Instatic routes Mutative-generated patches through Y.UndoManager instances bound to specific Y-Docs, allowing the editor to undo changes consistently across both local state and synchronized collaborative documents.

How do React components trigger undo in Instatic?

Components import the useUndo hook from src/admin/pages/site/store/store.ts. This hook returns the undo action function, which can be bound to keyboard events (such as CMD+Z) or UI buttons. When invoked, it executes the undo logic defined in undoRedoActions.ts, walking the stored history route and reverting changes via Y.UndoManager.

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 →