Undo / redo with a custom UI
In this tutorial, you will build external Undo and Redo buttons that stay in sync with the Handsontable undo/redo stack. You will learn how to use afterChange, afterUndo, and afterRedo to keep button states accurate at all times.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';registerAllModules();/* start:skip-in-preview */const data = [ { id: 1, task: 'Write release notes', status: 'Done', owner: 'Mia' }, { id: 2, task: 'Update API docs', status: 'In progress', owner: 'Owen' }, { id: 3, task: 'Review recipes', status: 'Blocked', owner: 'Lena' }, { id: 4, task: 'Ship hotfix', status: 'Done', owner: 'Kai' },];/* end:skip-in-preview */const rootContainer = document.querySelector('#example1');if (rootContainer instanceof HTMLElement) { const toolbar = document.createElement('div'); const controls = document.createElement('div'); const gridContainer = document.createElement('div'); toolbar.className = 'example-controls-container'; controls.className = 'controls'; const undoButton = document.createElement('button'); const redoButton = document.createElement('button'); undoButton.type = 'button'; undoButton.textContent = 'Undo'; redoButton.type = 'button'; redoButton.textContent = 'Redo'; controls.appendChild(undoButton); controls.appendChild(redoButton); toolbar.appendChild(controls); rootContainer.appendChild(toolbar); rootContainer.appendChild(gridContainer); const hot = new Handsontable(gridContainer, { data, colHeaders: ['ID', 'Task', 'Status', 'Owner'], rowHeaders: true, width: '100%', height: 'auto', autoWrapRow: true, autoWrapCol: true, undoRedo: true, columns: [ { data: 'id', type: 'numeric', width: 60, readOnly: true }, { data: 'task', type: 'text', width: 220 }, { data: 'status', type: 'text', width: 130 }, { data: 'owner', type: 'text', width: 120 }, ], afterChange: (_changes, source) => { if (source !== 'loadData') { updateButtonsState(); } }, afterUndo: () => { updateButtonsState(); }, afterRedo: () => { updateButtonsState(); }, licenseKey: 'non-commercial-and-evaluation', }); const undoRedoPlugin = hot.getPlugin('undoRedo'); function updateButtonsState() { undoButton.disabled = !undoRedoPlugin.isUndoAvailable(); redoButton.disabled = !undoRedoPlugin.isRedoAvailable(); } undoButton.addEventListener('click', () => { undoRedoPlugin.undo(); updateButtonsState(); }); redoButton.addEventListener('click', () => { undoRedoPlugin.redo(); updateButtonsState(); }); updateButtonsState();}import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
/* start:skip-in-preview */const data = [ { id: 1, task: 'Write release notes', status: 'Done', owner: 'Mia' }, { id: 2, task: 'Update API docs', status: 'In progress', owner: 'Owen' }, { id: 3, task: 'Review recipes', status: 'Blocked', owner: 'Lena' }, { id: 4, task: 'Ship hotfix', status: 'Done', owner: 'Kai' },];/* end:skip-in-preview */
const rootContainer = document.querySelector('#example1');
if (rootContainer instanceof HTMLElement) { const toolbar = document.createElement('div'); const controls = document.createElement('div'); const gridContainer = document.createElement('div');
toolbar.className = 'example-controls-container'; controls.className = 'controls';
const undoButton = document.createElement('button'); const redoButton = document.createElement('button');
undoButton.type = 'button'; undoButton.textContent = 'Undo'; redoButton.type = 'button'; redoButton.textContent = 'Redo';
controls.appendChild(undoButton); controls.appendChild(redoButton); toolbar.appendChild(controls); rootContainer.appendChild(toolbar); rootContainer.appendChild(gridContainer);
const hot = new Handsontable(gridContainer, { data, colHeaders: ['ID', 'Task', 'Status', 'Owner'], rowHeaders: true, width: '100%', height: 'auto', autoWrapRow: true, autoWrapCol: true, undoRedo: true, columns: [ { data: 'id', type: 'numeric', width: 60, readOnly: true }, { data: 'task', type: 'text', width: 220 }, { data: 'status', type: 'text', width: 130 }, { data: 'owner', type: 'text', width: 120 }, ], afterChange: (_changes, source) => { if (source !== 'loadData') { updateButtonsState(); } }, afterUndo: () => { updateButtonsState(); }, afterRedo: () => { updateButtonsState(); }, licenseKey: 'non-commercial-and-evaluation', }); const undoRedoPlugin = hot.getPlugin('undoRedo');
function updateButtonsState() { undoButton.disabled = !undoRedoPlugin.isUndoAvailable(); redoButton.disabled = !undoRedoPlugin.isRedoAvailable(); }
undoButton.addEventListener('click', () => { undoRedoPlugin.undo(); updateButtonsState(); });
redoButton.addEventListener('click', () => { undoRedoPlugin.redo(); updateButtonsState(); });
updateButtonsState();}Overview
This recipe shows how to connect external Undo and Redo buttons to Handsontable’s built-in undo/redo stack. The buttons stay disabled until an action is available, and they update after every change, undo, and redo.
Difficulty: Beginner Time: ~10 minutes Libraries: None (pure Handsontable APIs)
What You’ll Build
A grid with two custom buttons rendered outside the table that:
- Call
hot.getPlugin('undoRedo').undo()andhot.getPlugin('undoRedo').redo()on click. - Read stack availability from
hot.getPlugin('undoRedo').isUndoAvailable()andhot.getPlugin('undoRedo').isRedoAvailable(). - Toggle disabled state reactively after
afterChange,afterUndo, andafterRedo.
Prerequisites
None.
Import dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';registerAllModules();Add external controls
Use a container with two buttons above the grid:
<div class="undo-redo-controls"><button id="undo-button" type="button">Undo</button><button id="redo-button" type="button">Redo</button></div><div id="example1"></div>Enable undo/redo in grid settings
Turn on the plugin with
undoRedo: true.const hot = new Handsontable(container, {data,rowHeaders: true,colHeaders: true,undoRedo: true,licenseKey: 'non-commercial-and-evaluation',});Wire custom button handlers
Attach click listeners that call the core APIs.
undoButton.addEventListener('click', () => {hot.getPlugin('undoRedo').undo();});redoButton.addEventListener('click', () => {hot.getPlugin('undoRedo').redo();});Toggle button state from stack availability
Read plugin state and disable buttons when actions are unavailable.
const syncHistoryButtons = () => {const undoRedoPlugin = hot.getPlugin('undoRedo');undoButton.disabled = !undoRedoPlugin.isUndoAvailable();redoButton.disabled = !undoRedoPlugin.isRedoAvailable();};Keep state in sync after every update
Run
syncHistoryButtons()from all required hooks:afterChange: () => syncHistoryButtons(),afterUndo: () => syncHistoryButtons(),afterRedo: () => syncHistoryButtons(),Also call it once after initialization so both buttons start disabled.
Complete example
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
const undoButton = document.querySelector('#undo-button') as HTMLButtonElement;const redoButton = document.querySelector('#redo-button') as HTMLButtonElement;const container = document.querySelector('#example1')!;
const data = [ ['Task', 'Owner', 'Status'], ['Review PR', 'Alex', 'Done'], ['Update docs', 'Mira', 'In progress'], ['Plan release', 'Sam', 'Planned'],];
const hot = new Handsontable(container, { data, rowHeaders: true, colHeaders: true, undoRedo: true, width: '100%', height: 'auto', licenseKey: 'non-commercial-and-evaluation',});
const syncHistoryButtons = () => { const undoRedoPlugin = hot.getPlugin('undoRedo');
undoButton.disabled = !undoRedoPlugin.isUndoAvailable(); redoButton.disabled = !undoRedoPlugin.isRedoAvailable();};
undoButton.addEventListener('click', () => { hot.getPlugin('undoRedo').undo();});
redoButton.addEventListener('click', () => { hot.getPlugin('undoRedo').redo();});
hot.updateSettings({ afterChange: () => syncHistoryButtons(), afterUndo: () => syncHistoryButtons(), afterRedo: () => syncHistoryButtons(),});
syncHistoryButtons();How it works - complete flow
- The grid starts with
undoRedo: true. - Both buttons are disabled on load - the stack is empty.
- After any edit,
afterChangeruns and enables Undo. - Clicking Undo calls
hot.getPlugin('undoRedo').undo(), thenafterUndoupdates both buttons. - Clicking Redo calls
hot.getPlugin('undoRedo').redo(), thenafterRedoupdates both buttons. - Button states always match the plugin stack.
Related APIs
What you learned
- How to enable the
UndoRedoplugin withundoRedo: truein Handsontable settings. - How to call
undo()andredo()on the plugin instance from external button click handlers. - How to use the
afterChange,afterUndo, andafterRedohooks to checkisUndoAvailable()andisRedoAvailable()and keep button disabled states accurate. - How to keep the undo/redo stack in sync with the UI so buttons always reflect the actual stack state.
Next steps
- Add keyboard shortcuts (
Ctrl+Z,Ctrl+Shift+Z) using the ShortcutManager to supplement the buttons. - Explore auto-save changes to a backend to persist changes after each successful undo/redo cycle.