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
The editor instance to add history to
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
})
]
});
Milliseconds to wait before creating a new history entry
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>;
}
Stack of states that can be undone
Stack of states that can be redone
HistoryStateEntry
Single entry in history stack.
type HistoryStateEntry = {
editor: LexicalEditor;
editorState: EditorState;
}
The editor that created this state
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:
- Time-based merging: Changes within the delay period (default 300ms) are merged
- Type-based merging: Similar changes (typing, deleting) are merged together
- Composition handling: IME composition is treated specially
- 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.
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