Skip to content
← Writing

Undo at the write boundary

Undo works best when product intent decides what belongs in history.

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

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 writes as:

  • 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 needs three facts: the previous state, the next state, and the kind of 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 present 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:

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 write primitive stays 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. 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.

The 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 snapshots also remove them. 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:

type HistoryEntry<TState> = {
  state: TState;
  restore?: {
    focusId?: string;
    selection?: TextSelection;
    scrollIntoView?: boolean;
  };
};
 
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.

Keep navigation separate

Some interface changes deserve a timeline without becoming app undo.

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

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: pushState for a navigable place, replaceState for a refinement of the current place. 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.

const { state, dispatch, history } = useHistoryReducer(
  insightBuilderReducer,
  initialState,
);

The reducer does not know history exists. 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 baseline

useHistoryReducer() intercepts dispatch, runs the reducer, and pushes only when state changes. One call to dispatch is one undo step.

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

Keep that as 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,
  },
);

The wrapper filters optional entries, reduces the actions in order, then lets applyChange() compare the final state with the starting state:

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.

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. Saving records durability, not intent. Reset when the product crosses an explicit boundary: loading another document, accepting imported data, submitting a builder flow, or 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 state. Let the write boundary decide whether that state is an undo step, a transient preview, or a new baseline.