How Instatic's Editor Store Uses Zustand and Mutative for Undo History
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 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. 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.
// 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. 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. 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.
// 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. This utility wraps mutative recipes, extracts the generated patch pairs, and conditionally pushes them onto the undo stack.
// 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.
// 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:
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-mutativemiddleware 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
_historyCoalesceKeymerge into single undo entries, preventing history pollution from continuous input actions. - Selective Tracking: The
mutateSitehelper ensures only structural document changes enter the undo stack, excluding transient UI state like selection or scroll position. - Stable Hooks:
useUndoanduseRedoprovide 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 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.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →