Skip to main content

Overview

The @lexical/history package provides undo/redo functionality for Lexical editors, with intelligent merging of changes and configurable delay settings.

Installation

npm install @lexical/history

Core Functions

registerHistory

Registers undo/redo history functionality on an editor.
function registerHistory(
  editor: LexicalEditor,
  historyState: HistoryState,
  delay: number | ReadonlySignal<number>
): () => void
editor
LexicalEditor
required
The editor instance to add history to
historyState
HistoryState
required
The history state object (use createEmptyHistoryState())
delay
number | ReadonlySignal<number>
required
Milliseconds to wait before creating a new history entry (default: 300)
Returns: Cleanup function to unregister listeners Example:
import { registerHistory, createEmptyHistoryState } from '@lexical/history';

const historyState = createEmptyHistoryState();
const unregister = registerHistory(editor, historyState, 300);

// Later, cleanup:
unregister();

createEmptyHistoryState

Creates an empty history state object.
function createEmptyHistoryState(): HistoryState
Returns: Empty history state with empty undo/redo stacks Example:
import { createEmptyHistoryState } from '@lexical/history';

const historyState = createEmptyHistoryState();
// historyState = { current: null, undoStack: [], redoStack: [] }

Commands Registered

The history plugin automatically handles these commands:

UNDO_COMMAND

Reverts the last change.
const UNDO_COMMAND: LexicalCommand<void>
Example:
import { UNDO_COMMAND } from 'lexical';

editor.dispatchCommand(UNDO_COMMAND, undefined);

REDO_COMMAND

Reapplies a previously undone change.
const REDO_COMMAND: LexicalCommand<void>
Example:
import { REDO_COMMAND } from 'lexical';

editor.dispatchCommand(REDO_COMMAND, undefined);

CAN_UNDO_COMMAND

Dispatched when undo availability changes.
const CAN_UNDO_COMMAND: LexicalCommand<boolean>
Example:
import { CAN_UNDO_COMMAND } from 'lexical';

editor.registerCommand(
  CAN_UNDO_COMMAND,
  (canUndo: boolean) => {
    undoButton.disabled = !canUndo;
    return false;
  },
  COMMAND_PRIORITY_LOW
);

CAN_REDO_COMMAND

Dispatched when redo availability changes.
const CAN_REDO_COMMAND: LexicalCommand<boolean>
Example:
import { CAN_REDO_COMMAND } from 'lexical';

editor.registerCommand(
  CAN_REDO_COMMAND,
  (canRedo: boolean) => {
    redoButton.disabled = !canRedo;
    return false;
  },
  COMMAND_PRIORITY_LOW
);

CLEAR_HISTORY_COMMAND

Clears all undo/redo history.
const CLEAR_HISTORY_COMMAND: LexicalCommand<void>
Example:
import { CLEAR_HISTORY_COMMAND } from 'lexical';

editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);

Extensions

HistoryExtension

Bundles history functionality with configuration.
import { createEditor } from 'lexical';
import { HistoryExtension } from '@lexical/history';

const editor = createEditor({
  extensions: [
    HistoryExtension.configure({
      delay: 300,
      disabled: false
    })
  ]
});
delay
number
default:"300"
Milliseconds to wait before creating a new history entry
disabled
boolean
default:"false"
Whether history is disabled (useful for server-side rendering)
createInitialHistoryState
(editor: LexicalEditor) => HistoryState
Function to create initial history state (defaults to createEmptyHistoryState)

SharedHistoryExtension

Shares history state with a parent editor (for nested editors).
import { createEditor } from 'lexical';
import { SharedHistoryExtension } from '@lexical/history';

const nestedEditor = createEditor({
  extensions: [SharedHistoryExtension],
  parentEditor: parentEditor
});
SharedHistoryExtension allows nested editors to use their parent’s undo/redo stack.

Types

HistoryState

State object tracking undo/redo history.
type HistoryState = {
  current: null | HistoryStateEntry;
  redoStack: Array<HistoryStateEntry>;
  undoStack: Array<HistoryStateEntry>;
}
current
HistoryStateEntry | null
The current editor state
undoStack
HistoryStateEntry[]
Stack of states that can be undone
redoStack
HistoryStateEntry[]
Stack of states that can be redone

HistoryStateEntry

Single entry in history stack.
type HistoryStateEntry = {
  editor: LexicalEditor;
  editorState: EditorState;
}
editor
LexicalEditor
The editor that created this state
editorState
EditorState
The frozen editor state at this point in history

HistoryConfig

Configuration for HistoryExtension.
type HistoryConfig = {
  delay: number;
  createInitialHistoryState: (editor: LexicalEditor) => HistoryState;
  disabled: boolean;
}

How History Works

Change Merging

History intelligently merges changes to create logical undo/redo points:
  1. Time-based merging: Changes within the delay period (default 300ms) are merged
  2. Type-based merging: Similar changes (typing, deleting) are merged together
  3. Composition handling: IME composition is treated specially
  4. Selection-only changes: Don’t create new history entries

Change Types Detected

  • Character insertion: Typing characters consecutively
  • Character deletion: Backspace/Delete keystrokes
  • Composition: IME input for non-Latin languages
  • Other: Formatting, node insertion, etc.

Tags for Manual Control

You can control history behavior with update tags:
// Force new history entry
editor.update(() => {
  // changes...
}, { tag: HISTORY_PUSH_TAG });

// Merge with current history entry
editor.update(() => {
  // changes...
}, { tag: HISTORY_MERGE_TAG });

// Skip history (for undo/redo operations)
editor.update(() => {
  // changes...
}, { tag: HISTORIC_TAG });

Complete Example

import { createEditor } from 'lexical';
import { 
  HistoryExtension,
  createEmptyHistoryState,
  registerHistory
} from '@lexical/history';
import {
  UNDO_COMMAND,
  REDO_COMMAND,
  CAN_UNDO_COMMAND,
  CAN_REDO_COMMAND,
  CLEAR_HISTORY_COMMAND
} from 'lexical';

// Using extension (recommended)
const editor = createEditor({
  extensions: [
    HistoryExtension.configure({
      delay: 300,
      disabled: false
    })
  ]
});

// Or manual setup
const editor2 = createEditor({});
const historyState = createEmptyHistoryState();
const unregister = registerHistory(editor2, historyState, 300);

editor.setRootElement(document.getElementById('editor'));

// Set up UI buttons
const undoButton = document.getElementById('undo-btn');
const redoButton = document.getElementById('redo-btn');
const clearButton = document.getElementById('clear-history-btn');

// Handle undo/redo button states
editor.registerCommand(
  CAN_UNDO_COMMAND,
  (canUndo: boolean) => {
    undoButton.disabled = !canUndo;
    return false;
  },
  COMMAND_PRIORITY_LOW
);

editor.registerCommand(
  CAN_REDO_COMMAND,
  (canRedo: boolean) => {
    redoButton.disabled = !canRedo;
    return false;
  },
  COMMAND_PRIORITY_LOW
);

// Wire up buttons
undoButton.addEventListener('click', () => {
  editor.dispatchCommand(UNDO_COMMAND, undefined);
});

redoButton.addEventListener('click', () => {
  editor.dispatchCommand(REDO_COMMAND, undefined);
});

clearButton.addEventListener('click', () => {
  editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
});

// Keyboard shortcuts (Ctrl+Z, Ctrl+Y)
editor.registerCommand(
  KEY_Z_COMMAND,
  (event: KeyboardEvent) => {
    if (event.ctrlKey || event.metaKey) {
      event.preventDefault();
      if (event.shiftKey) {
        editor.dispatchCommand(REDO_COMMAND, undefined);
      } else {
        editor.dispatchCommand(UNDO_COMMAND, undefined);
      }
      return true;
    }
    return false;
  },
  COMMAND_PRIORITY_EDITOR
);

editor.registerCommand(
  KEY_Y_COMMAND,
  (event: KeyboardEvent) => {
    if (event.ctrlKey || event.metaKey) {
      event.preventDefault();
      editor.dispatchCommand(REDO_COMMAND, undefined);
      return true;
    }
    return false;
  },
  COMMAND_PRIORITY_EDITOR
);

Advanced: Custom History Logic

import { 
  createEmptyHistoryState, 
  registerHistory,
  type HistoryState 
} from '@lexical/history';
import { signal } from '@lexical/extension';

// Dynamic delay based on user activity
const delaySignal = signal(300);

const historyState = createEmptyHistoryState();
const unregister = registerHistory(editor, historyState, delaySignal);

// Increase delay when user is typing fast
let lastKeystroke = Date.now();
editor.registerCommand(
  KEY_DOWN_COMMAND,
  () => {
    const now = Date.now();
    const timeSinceLastKey = now - lastKeystroke;
    
    if (timeSinceLastKey < 100) {
      // Fast typing - merge more aggressively
      delaySignal.value = 1000;
    } else {
      // Normal typing
      delaySignal.value = 300;
    }
    
    lastKeystroke = now;
    return false;
  },
  COMMAND_PRIORITY_LOW
);

Shared History for Nested Editors

import { createEditor } from 'lexical';
import { HistoryExtension, SharedHistoryExtension } from '@lexical/history';

// Parent editor with its own history
const parentEditor = createEditor({
  extensions: [HistoryExtension]
});

// Nested editor sharing parent's history
const nestedEditor = createEditor({
  extensions: [SharedHistoryExtension],
  parentEditor: parentEditor
});

// Now undo/redo works across both editors
parentEditor.dispatchCommand(UNDO_COMMAND, undefined);
// This affects both parent and nested editor

Build docs developers (and LLMs) love