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 { useRef, useState } from 'react';import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import './example1.css';
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 ExampleComponent = () => { const hotRef = useRef(null); const [undoDisabled, setUndoDisabled] = useState(true); const [redoDisabled, setRedoDisabled] = useState(true);
const updateButtonsState = () => { const hot = hotRef.current?.hotInstance;
if (!hot) { return; }
const undoRedoPlugin = hot.getPlugin('undoRedo');
setUndoDisabled(!undoRedoPlugin.isUndoAvailable()); setRedoDisabled(!undoRedoPlugin.isRedoAvailable()); };
const handleUndo = () => { hotRef.current?.hotInstance?.getPlugin('undoRedo').undo(); updateButtonsState(); };
const handleRedo = () => { hotRef.current?.hotInstance?.getPlugin('undoRedo').redo(); updateButtonsState(); };
return ( <> <div className="undo-redo-controls"> <button type="button" onClick={handleUndo} disabled={undoDisabled}> Undo </button> <button type="button" onClick={handleRedo} disabled={redoDisabled}> Redo </button> </div> <HotTable ref={hotRef} data={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" /> </> );};
export default ExampleComponent;import { useRef, useState } from 'react';import { HotTable, HotTableRef } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import './example1.css';
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 ExampleComponent = () => { const hotRef = useRef<HotTableRef>(null); const [undoDisabled, setUndoDisabled] = useState(true); const [redoDisabled, setRedoDisabled] = useState(true);
const updateButtonsState = (): void => { const hot = hotRef.current?.hotInstance;
if (!hot) { return; }
const undoRedoPlugin = hot.getPlugin('undoRedo');
setUndoDisabled(!undoRedoPlugin.isUndoAvailable()); setRedoDisabled(!undoRedoPlugin.isRedoAvailable()); };
const handleUndo = (): void => { hotRef.current?.hotInstance?.getPlugin('undoRedo').undo(); updateButtonsState(); };
const handleRedo = (): void => { hotRef.current?.hotInstance?.getPlugin('undoRedo').redo(); updateButtonsState(); };
return ( <> <div className="undo-redo-controls"> <button type="button" onClick={handleUndo} disabled={undoDisabled}> Undo </button> <button type="button" onClick={handleRedo} disabled={redoDisabled}> Redo </button> </div> <HotTable ref={hotRef} data={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" /> </> );};
export default ExampleComponent;.undo-redo-controls { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem;}
.undo-redo-controls button { border: 1px solid var(--sl-color-gray-5); background: var(--sl-color-bg-nav); color: var(--sl-color-text); font-size: var(--sl-text-sm); line-height: 1.2; padding: 0.4rem 0.75rem; border-radius: 0; cursor: pointer; transition: color 0.15s, background-color 0.15s, border-color 0.15s;}
.undo-redo-controls button:not(:disabled):hover { color: var(--sl-color-white); background: var(--sl-color-gray-6);}
.undo-redo-controls button:focus-visible { outline: 1px solid var(--sl-color-accent); outline-offset: 1px;}
.undo-redo-controls button:disabled { color: var(--sl-color-gray-3); border-color: var(--sl-color-gray-5); background: var(--sl-color-gray-7); opacity: 0.8; cursor: not-allowed;}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.