Undo at the write boundary
Undo works best when product intent decides what belongs in history.
· 10 min read
Undo starts as a small data structure for Z, then turns into a product question.
The data structure is familiar: keep a past, a present, and a future. The product question is what counts as one step in the user's editing timeline. Moving a column, resizing a block, applying three defaults from a template, and dragging a shape across a canvas all write state. They should not all create the same kind of history entry.
That is where undo gets interesting. A write can be a user decision, a live preview, a baseline replacement, or navigation through the interface. The reducer can describe how state changes, but it should not have to know which of those meanings the write has.
Put that decision at the write boundary: the one place where current state becomes next state. Let domain code produce the next value. Let the history layer decide whether that value becomes part of the user's timeline.
Most editing surfaces need to classify four kinds of writes:
- User decisions that should become undo steps.
- Transient writes like selection, hover, viewport, focus, and open panels.
- Baseline changes like loading a document or saving one.
- Navigation changes like moving between routes, panels, or previously visited views.
The bug is treating them all the same.
The write boundary
The pattern needs one controlled way to turn current state into next state:
history.apply((current) => update(current, change));That shape can wrap a useState setter, a reducer dispatch, a command executor, a canvas model, or an external store. The core does not need React, Redux, Zustand, or a specific editor. It needs three facts: the previous state, the next state, and the kind of write.
Keeping undo there keeps state logic pure. Domain code produces the next value. The history layer records an undo step, replaces the preview, resets the baseline, or ignores the write.
The model
The smallest model is three stacks:
type HistoryState<TState> = {
past: TState[];
present: TState;
future: TState[];
};present is the current state. past is what the user can undo to. future is what the user can redo after undoing.
Undo moves the current state into future, then restores the latest state from past:
function undo<TState>(history: HistoryState<TState>): HistoryState<TState> {
if (history.past.length === 0) return history;
const previous = history.past[history.past.length - 1];
return {
past: history.past.slice(0, -1),
present: previous,
future: [history.present, ...history.future],
};
}Redo is the inverse: move present into past, then restore the next state from future.
function redo<TState>(history: HistoryState<TState>): HistoryState<TState> {
if (history.future.length === 0) return history;
const next = history.future[0];
return {
past: [...history.past, history.present],
present: next,
future: history.future.slice(1),
};
}An edit pushes present into past, replaces it with the next state, and clears future. Once the user edits after undoing, the abandoned forward path is gone:
function push<TState>(
history: HistoryState<TState>,
nextPresent: TState,
maxSize: number,
): HistoryState<TState> {
const past = [...history.past, history.present];
return {
past: past.length > maxSize ? past.slice(past.length - maxSize) : past,
present: nextPresent,
future: [],
};
}With that model, the undoable write primitive can stay small:
const MAX_HISTORY_SIZE = 100;
function applyChange<TState>(
history: HistoryState<TState>,
update: (state: TState) => TState,
): HistoryState<TState> {
const nextPresent = update(history.present);
if (isEqual(history.present, nextPresent)) return history;
return push(history, nextPresent, MAX_HISTORY_SIZE);
}Use semantic equality here. Shallow equality misses reducers that return a structurally identical object with a new reference. In production, isEqual should be domain-specific when possible. Generic deep equality is fine for small local state, but expensive for large canvases or editors.
MAX_HISTORY_SIZE keeps long sessions bounded.
Do not record every write
History should contain what undo is responsible for, not every bit of UI needed to render the editor.
Sometimes that includes interaction state. Text editors often undo content together with the caret position or text selection because the user is still editing in that same place. A diagram tool might restore the selected node after undoing its deletion because that is where the next action belongs.
The important distinction is intent. Does changing this field deserve its own undo step? Or should it only help the user recover their working context after an undo step changes the document?
For many builders, the durable document and the editor chrome are separate:
type BuilderDocument = {
blocks: Block[];
connections: Connection[];
};
type BuilderEditorState = {
selection: SelectionState;
viewport: ViewportState;
activePanel: PanelId | null;
};
const [documentHistory, setDocumentHistory] = useState(initialHistory);
const [editorState, setEditorState] = useState(initialEditorState);Selection, viewport, active panels, and focus can then change without polluting the document timeline. If selection itself is a meaningful undoable decision in your product, make it part of the history domain deliberately. If it only helps the user continue after another decision is undone, treat it as restoration context.
Do not paper over mixed state by making isEqual ignore transient fields unless your snapshots also remove them. If old history entries still contain old selections, undo will restore them later whether equality ignored them or not. Equality decides whether a state is recorded; snapshots decide what gets restored.
When undo should restore working context, attach that context to the history entry or handle it in the command that performs undo. The simple TState[] stacks can grow into entry stacks:
type HistoryEntry<TState> = {
state: TState;
restore?: {
focusId?: string;
selection?: TextSelection;
scrollIntoView?: boolean;
};
};With that shape, past and future become HistoryEntry<TState>[], and present can stay as the current state:
type HistoryState<TState> = {
past: HistoryEntry<TState>[];
present: TState;
future: HistoryEntry<TState>[];
};Restoring focus should help the user continue from the undone decision, not teleport them somewhere surprising. Native text undo inside a focused input should usually stay local to that input. App-level undo should move focus only when the restored state needs an interaction target.
Keep navigation separate
Some interface changes deserve a timeline without becoming app undo.
Browsers, IDEs, and tools like Slack all have back and forward navigation. Moving between conversations, opening a thread, visiting a search result, and returning to a previous view are navigation steps. They help the user move through places they visited. They do not mean "reverse the last edit I made."
That is the distinction:
- Undo and redo are for editing history. They reverse changes to the user's work.
- Back and forward are for navigation history. They restore a previously visited place, route, view, or panel.
- Local text undo belongs to the focused text field until the app deliberately takes ownership of the command.
Panels can live on either side, depending on what they mean. Opening an inspector panel after selecting a chart is usually restoration context. Opening a thread, search result, or detail view can be navigation. Changing a setting inside a panel can be an undoable edit. Do not choose based on the component type. Choose based on the user question each command answers: "take me back to where I was" or "reverse what I changed."
Use navigation history when a UI state is a place the user may want to revisit. Use undo history when a state is work the user may want to reverse. If one gesture does both, keep the histories separate: commit the document change to undo, then move the user through navigation or restoration context.
The URL follows the same rule. Use pushState when the URL change creates a navigable place, like opening a search result or switching to a shareable view. Use replaceState when the URL only refines the current place, like changing a filter while the user is still composing the same query. Do not mirror every editor write into the URL, and do not make app undo compete with the browser back button.
Adapting it to a reducer
A reducer is a convenient adapter because it already has the shape history wants: current state and action in, next state out.
The adapter should be thin. The vanilla history core owns push, replacePresent, commitFrom, undo, redo, and reset. The React hook only connects that core to useState and a reducer.
const { state, dispatch, history } = useHistoryReducer(
insightBuilderReducer,
initialState,
);The reducer does not know history exists. It receives the same actions and returns the same next state. Undo and redo sit beside dispatch:
history.undo();
history.redo();
history.canUndo; // boolean for disabling the undo button
history.canRedo;
history.reset(nextState); // clear history at a new baselineuseHistoryReducer() intercepts dispatch, runs the reducer, and pushes only when state changes. One call to dispatch is one undo step. Most calls pass one action, but the same boundary can accept several actions when several reducer writes are one user decision:
export function useHistoryReducer<TState, TAction>(
reducer: (state: TState, action: TAction) => TState,
initialState: TState,
) {
const [historyState, setHistoryState] = useState<HistoryState<TState>>({
past: [],
present: initialState,
future: [],
});
function dispatch(...actions: Array<TAction | null | undefined>) {
const batch = actions.filter(
(action): action is TAction => action !== null && action !== undefined,
);
setHistoryState((current) =>
applyChange(current, (present) => reduceBatch(present, batch, reducer)),
);
}
return {
state: historyState.present,
dispatch,
history: {
undo: () => setHistoryState(undo),
redo: () => setHistoryState(redo),
reset: (nextState: TState) =>
setHistoryState({
past: [],
present: nextState,
future: [],
}),
canUndo: historyState.past.length > 0,
canRedo: historyState.future.length > 0,
},
};
}Batch the user's intent
Some UI gestures are made up of many reducer actions but should undo as one step. Adding a widget might create the widget and append any dashboard variables it depends on. Technically, several things happened. To the user, it was one decision: "add this widget."
That is still one dispatch:
const { dispatch } = useHistoryReducer(builderReducer, initialState);
dispatch(
{
type: 'ADD_WIDGET',
payload: result.widgetInput,
},
result.newDashboardVariables.length > 0 && {
type: 'APPEND_DASHBOARD_VARIABLES',
payload: result.newDashboardVariables,
},
);Optional action entries keep conditionals local to the call site. The wrapper filters them out, reduces the document actions in order, then lets applyChange() compare the final state with the starting state and push one entry:
function reduceBatch<TState, TAction>(
state: TState,
actions: TAction[],
reducer: (state: TState, action: TAction) => TState,
): TState {
let nextState = state;
for (const action of actions) {
nextState = reducer(nextState, action);
}
return nextState;
}The reducer still handles one action at a time. The call site names the user intent: every dispatch() call is one undo step, whether it contains one action or a short sequence of actions. UI movement around the edit can happen beside the dispatch without entering the document timeline.
Reset baselines deliberately
reset() replaces present and clears both stacks. Use it when a persisted or loaded state becomes the new editing baseline:
async function handleSave() {
await saveInsight(state);
history.reset(state);
}Users can still undo new edits. They just cannot undo to a state before the baseline.
Do not reset on every autosave by default. Many document editors let users undo through saves because saving records durability, not intent. Reset when the product crosses an explicit boundary: loading another document, accepting imported data as the new starting point, submitting a builder flow, or intentionally discarding the previous local timeline.
When not to use this
Skip this for simple forms where undo means "discard all changes"; a cancel button is the right primitive. Skip it for server-backed CRUD where undoing requires API calls; that is optimistic updates and server reconciliation, not local history.
Use this pattern when users make a series of local edits before committing them: visual builders, query builders, config editors, diagram tools.
The test is simple: if the user thinks in steps, give their decisions a timeline. Let reducers, setters, and commands produce the next state. Let the write boundary decide whether that state is an undo step, a transient preview, or a new baseline.