typescript 135 lines · 3 tabs

Undo/Redo in a React Text Editor with a Command-History Stack

Shared by codesnips Sep 2026
3 tabs
export interface DocState {
  text: string;
  selection: number;
}

export interface Command<S> {
  apply(state: S): S;
  invert(state: S): Command<S>;
}

export function insertText(offset: number, chars: string): Command<DocState> {
  return {
    apply(state) {
      const text = state.text.slice(0, offset) + chars + state.text.slice(offset);
      return { text, selection: offset + chars.length };
    },
    invert() {
      return deleteRange(offset, chars);
    },
  };
}

export function deleteRange(offset: number, removed: string): Command<DocState> {
  return {
    apply(state) {
      const end = offset + removed.length;
      const text = state.text.slice(0, offset) + state.text.slice(end);
      return { text, selection: offset };
    },
    invert() {
      // The removed slice was captured at creation time, so re-insertion is exact.
      return insertText(offset, removed);
    },
  };
}
3 files · typescript Explain with highlit

This snippet implements undo/redo for a text editor using the command pattern backed by two stacks. The core idea is that every mutation is represented as a reversible Command object rather than being applied directly to state, so history can be replayed forward or backward deterministically.

In commands.ts, a Command<S> interface defines apply and invertapply produces the next state, and invert produces a command that undoes the effect. insertText and deleteRange are factory functions returning such commands. deleteRange captures the removed substring inside its closure at creation time, which is what lets its inverse re-insert exactly the right text at the right offset. Capturing the removed slice up front avoids the classic bug where an inverse tries to recompute deleted content after the fact and gets it wrong.

useHistory.ts holds three pieces of state: the current document present, an undoStack, and a redoStack. execute applies a command, pushes it onto the undo stack, and clears the redo stack — clearing redo is deliberate, since branching off a mid-history point invalidates the previously abandoned future. undo pops the last command, applies its invert against the current state, and moves it to the redo stack; redo reverses that flow. Because commands are the unit of history, memory stays bounded by the number of edits rather than by snapshotting the entire document on every keystroke.

The hook exposes canUndo/canredo flags derived from stack lengths so the UI can disable buttons cleanly. useCallback keeps the returned handlers stable across renders, which matters when they are wired to keyboard shortcuts.

Editor.tsx ties it together: it renders a textarea, translates raw onChange diffs into insertText or deleteRange commands, and binds Ctrl/Cmd+Z and Shift+Ctrl/Cmd+Z to undo and redo. Converting a coarse textarea change into a precise command is the trickiest part — the handler compares old and new values to find the common prefix and suffix. The main trade-off of this pattern is that authors must write correct inverses for every command; the payoff is O(1) undo, coalescing opportunities, and a history that is trivial to serialize or inspect. It is the right reach whenever edits are discrete and reversible.


Related snips

Share this code

Here's the card — post it anywhere.

Undo/Redo in a React Text Editor with a Command-History Stack — share card
Link copied