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.
/* file: app.component.ts */import { Component, ViewChild } from '@angular/core';import { GridSettings, HotTableComponent, HotTableModule } from '@handsontable/angular-wrapper';import { RowObject } from 'handsontable/common';import Handsontable from 'handsontable/base';
@Component({ standalone: true, imports: [HotTableModule], selector: 'example1-undo-redo-custom-ui', template: ` <div class="undo-redo-controls"> <button type="button" [disabled]="!isUndoAvailable" (click)="undo()">Undo</button> <button type="button" [disabled]="!isRedoAvailable" (click)="redo()">Redo</button> </div> <hot-table [data]="hotData" [settings]="hotSettings"></hot-table> `,})export class AppComponent { @ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent;
isUndoAvailable = false; isRedoAvailable = false;
readonly hotData = [ { 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' }, ];
readonly hotSettings: GridSettings = { colHeaders: ['ID', 'Task', 'Status', 'Owner'], rowHeaders: true, width: '100%', height: 'auto', autoWrapRow: true, autoWrapCol: 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: Handsontable.CellChange[] | null, source: Handsontable.ChangeSource) => { if (source !== 'loadData') { this.updateButtonsState(); } }, afterUndo: () => { this.updateButtonsState(); }, afterRedo: () => { this.updateButtonsState(); }, };
updateButtonsState(): void { const undoRedoPlugin = this.hotTable?.hotInstance?.getPlugin('undoRedo');
if (undoRedoPlugin) { this.isUndoAvailable = undoRedoPlugin.isUndoAvailable(); this.isRedoAvailable = undoRedoPlugin.isRedoAvailable(); } }
undo(): void { this.hotTable?.hotInstance?.getPlugin('undoRedo').undo(); this.updateButtonsState(); }
redo(): void { this.hotTable?.hotInstance?.getPlugin('undoRedo').redo(); this.updateButtonsState(); }}/* end-file */
/* file: app.config.ts */import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';import { registerAllModules } from 'handsontable/registry';import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';
registerAllModules();
export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), { provide: HOT_GLOBAL_CONFIG, useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, }, ],};/* end-file */<div> <example1-undo-redo-custom-ui></example1-undo-redo-custom-ui></div>.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.