Skip to content

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.

TypeScript
/* 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 */
HTML
<div>
<example1-undo-redo-custom-ui></example1-undo-redo-custom-ui>
</div>
CSS
.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() and hot.getPlugin('undoRedo').redo() on click.
  • Read stack availability from hot.getPlugin('undoRedo').isUndoAvailable() and hot.getPlugin('undoRedo').isRedoAvailable().
  • Toggle disabled state reactively after afterChange, afterUndo, and afterRedo.

Prerequisites

None.

  1. Import dependencies

    import Handsontable from 'handsontable/base';
    import { registerAllModules } from 'handsontable/registry';
    registerAllModules();
  2. 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>
  3. 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',
    });
  4. 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();
    });
  5. 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();
    };
  6. 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

  1. The grid starts with undoRedo: true.
  2. Both buttons are disabled on load - the stack is empty.
  3. After any edit, afterChange runs and enables Undo.
  4. Clicking Undo calls hot.getPlugin('undoRedo').undo(), then afterUndo updates both buttons.
  5. Clicking Redo calls hot.getPlugin('undoRedo').redo(), then afterRedo updates both buttons.
  6. Button states always match the plugin stack.

What you learned

  • How to enable the UndoRedo plugin with undoRedo: true in Handsontable settings.
  • How to call undo() and redo() on the plugin instance from external button click handlers.
  • How to use the afterChange, afterUndo, and afterRedo hooks to check isUndoAvailable() and isRedoAvailable() 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