# How Instatic's Editor Store Uses Zustand and Mutative for Undo History

> Learn how Instatic's editor store uses Zustand and Mutative for powerful undo history. Discover automatic state mutation patching for instant reversibility.

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

---

**Instatic's visual editor implements undo history by combining a Zustand global store with the `zustand-mutative` middleware, which automatically generates patch pairs for every state mutation to enable instant reversibility.**

In the [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic) repository, the visual site editor manages complex document trees that require granular undo and redo capabilities. The application leverages **Zustand** for performant state management and **Mutative** for immutable-friendly draft mutations, creating a patch-based undo system that records only the differences between states rather than full snapshots.

## Configuring the Zustand Store with Mutative Middleware

The central editor state lives in `useEditorStore`, defined in [`src/admin/pages/site/store/store.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/store.ts). The store combines multiple domain slices—site data, UI state, canvas settings, and selection—using Zustand's slice pattern, wrapped with the `mutative` middleware from `zustand-mutative`.

```ts
// src/admin/pages/site/store/store.ts
export const useEditorStore = create<EditorStore>()(
  subscribeWithSelector(
    mutative(
      (...args) => ({
        ...createSiteSlice(...args),
        ...createSelectionSlice(...args),
        ...createCanvasSlice(...args),
        ...createUiSlice(...args),
        ...createStyleRuleSlice(...args),
        ...createFilesSlice(...args),
        ...createVisualComponentsSlice(...args),
        ...createSettingsSlice(...args),
        ...createAgentSlice(siteAgentSliceConfig)(...args),
        ...createSitePanelSlice(...args),
        ...createClipboardSlice(...args),
        ...createInlineEditSlice(...args),
        ...createLayoutsSlice(...args),
        ...createSaveTrackingSlice(...args),
      }),
      { enableAutoFreeze: true },
    ),
  ),
);

```

The `mutative` middleware provides two critical capabilities. First, it allows slice logic to use **draft-mutation syntax** (mutating a draft object directly rather than returning new objects). Second, it automatically captures **patch pairs** describing each mutation, which the store uses to construct the undo history.

## How Mutative Generates Patch-Based History

Unlike traditional undo systems that save complete state snapshots, Instatic stores **patch pairs**—forward and inverse patches—that describe exactly what changed. When a slice mutates the site document via a mutative recipe, the middleware returns an `inverse` patch (to undo the change) and a `forward` patch (to redo it).

These patches are stored in the `_historyPast` array as `HistoryEntry` objects defined in [`src/admin/pages/site/store/slices/site/types.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/store/slices/site/types.ts). The `_historyFuture` array maintains the redo stack. This approach keeps memory usage constant regardless of document size, as each entry only records the diff, not the entire tree.

## Implementing Undo and Redo Actions

The undo and redo logic resides 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). When a user triggers undo, the system applies the **inverse** patches from the most recent history entry to the current site document using `mutative.apply`.

```ts
// src/admin/pages/site/store/slices/site/undoRedoActions.ts
export function createUndoRedoActions({ get, set }: SiteSliceHelpers): UndoRedoActions {
  return {
    undo: () => {
      const { _historyPast, site } = get();
      if (_historyPast.length === 0 || !site) return;
      const entry = _historyPast[_historyPast.length - 1]!;
      const restored = apply(site, entry.inverse);
      
      set(state => {
        state._historyPast.pop();
        state._historyFuture.push(entry);
        state._historyCoalesceKey = null;
        state.site = { ...restored, packageJson, runtime: siteRuntime };
      });
    },

    redo: () => {
      const { _historyFuture, site } = get();
      if (_historyFuture.length === 0 || !site) return;
      const entry = _historyFuture[_historyFuture.length - 1]!;
      const restored = apply(site, entry.forward);
      
      set(state => {
        state._historyFuture.pop();
        state._historyPast.push(entry);
        state.site = { ...restored, packageJson, runtime: siteRuntime };
      });
    },
  };
}

```

The `apply` function from Mutative takes the current state and a patch set, returning the modified state. After applying patches, the store updates the history stacks and recalculates derived data such as `packageJson` and `siteRuntime`.

## Coalescing Rapid Edits

To prevent every keystroke from creating a separate undo entry, Instatic implements a **coalescing** mechanism using the `_historyCoalesceKey` property. When a user begins a rapid sequence of edits (such as typing in a text field), the store assigns a unique key to that burst.

If subsequent mutations share the same coalesce key, they replace the previous history entry rather than appending a new one. This ensures that when the user stops typing and presses Ctrl+Z, the entire editing session reverts as a single action. The coalesce key is cleared when the burst ends or when a different action type occurs.

## The mutateSite Helper for Undoable Mutations

Every slice that modifies the site document uses the `mutateSite` helper located 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 utility wraps mutative recipes, extracts the generated patch pairs, and conditionally pushes them onto the undo stack.

```ts
// Conceptual implementation based on slices/site/helpers.ts
export const mutateSite = (recipe) => {
  const { site, _historyPast, _historyCoalesceKey } = get();
  const { inverse, forward } = applyDraft(site, recipe);
  
  if (hasRealChange(inverse)) {
    if (_historyCoalesceKey === currentKey) {
      _historyPast[_historyPast.length - 1] = { inverse, forward };
    } else {
      _historyPast.push({ inverse, forward });
    }
  }
  set(state => { state.site = apply(state.site, forward); });
};

```

The helper ensures that **only mutations producing real changes** enter the history, and it handles coalescing logic automatically. It also prevents UI-only state changes—such as selection updates or panel visibility—from polluting the undo stack by only tracking changes to the actual site document.

## Accessing Undo/Redo in React Components

The store exposes stable hooks for UI components to trigger history actions. Located alongside the store definition, these selectors return memoized function references compatible with React Compiler optimizations.

```ts
// src/admin/pages/site/store/store.ts
export const useUndo = () => useEditorStore(s => s.undo);
export const useRedo = () => useEditorStore(s => s.redo);

```

Components consume these hooks directly:

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

export function UndoButton() {
  const undo = useUndo();
  const canUndo = useEditorStore(s => s.canUndo);
  
  return (
    <button onClick={undo} disabled={!canUndo}>
      Undo
    </button>
  );
}

```

Because the hooks return stable references, components can safely include them in dependency arrays without causing unnecessary re-renders.

## Summary

- **Zustand with Mutative**: Instatic combines `zustand-mutative` middleware with a multi-slice store architecture to enable draft-based mutations while capturing change patches.
- **Patch-Based History**: The system stores forward and inverse patches rather than state snapshots, making undo/redo operations memory-efficient and instantaneous regardless of document size.
- **Coalescing**: Rapid edits grouped under a `_historyCoalesceKey` merge into single undo entries, preventing history pollution from continuous input actions.
- **Selective Tracking**: The `mutateSite` helper ensures only structural document changes enter the undo stack, excluding transient UI state like selection or scroll position.
- **Stable Hooks**: `useUndo` and `useRedo` provide performant access to history actions throughout the React component tree.

## Frequently Asked Questions

### Why does Instatic use Mutative instead of Immer for undo history?

Instatic uses Mutative specifically for its built-in **patch generation** capabilities. While Immer provides draft-based mutation, Mutative (via `zustand-mutative`) automatically produces the `inverse` and `forward` patch pairs required for undo/redo without additional manual diffing, and integrates natively with Zustand's middleware pattern.

### How does the editor prevent UI-only state from entering the undo history?

The `mutateSite` helper 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) acts as a gateway for all document modifications. UI slices like selection and canvas zoom update their state directly through Zustand, but only calls routed through `mutateSite` generate patches and push entries to `_historyPast`, ensuring the undo stack contains only reversible document changes.

### What happens when a user undoes a change and then makes a new edit?

When `undo` executes, it pops the entry from `_historyPast` and pushes it to `_historyFuture`. If the user performs a new mutation while entries exist in the future stack, Instatic clears `_historyFuture`, establishing a new timeline branch. This follows the standard destructive redo model where new edits invalidate previously redone states.

### How does the coalescing mechanism detect when to group mutations?

The store tracks a `_historyCoalesceKey` property. When mutations share the same key (set via `setCoalesceKey`), `mutateSite` replaces the top entry in `_historyPast` rather than appending. When the key changes or is cleared, the next mutation creates a fresh entry, closing the coalescing window and treating the grouped patches as a single undoable unit.