Events and hooks
Run your code before or after specific data grid actions, using Handsontable’s API hooks (callbacks). For example, control what happens with the user’s input.
Overview
Callbacks are used to react before or after actions occur. We refer to them as hooks. Handsontable’s hooks share some characteristics with events and middleware, combining them both in a unique structure.
If your logic grows beyond a single hook and needs shared state or a lifecycle, package it as a custom plugin instead.
Events
If you only react to emitted hooks and forget about all their other features, you can treat Handsontable’s hooks as pure events. You would want to limit your scope to after prefixed hooks, so they are emitted after something has happened and the results of the actions are already committed.
const hotSettings = ref({ afterCreateRow(row, amount) { console.log(`${amount} row(s) were created, starting at index ${row}`); }, // ...other settings});Middleware
Middleware is a concept known in the JavaScript world from Node.js frameworks such as Express or Koa. Middleware is a callback that can pipe to a process and allow the developer to modify it. We’re no longer just reacting to an emitted event, but we can influence what’s happening inside the component and modify the process.
const hotSettings = ref({ modifyColWidth(width, column) { if (column > 10) { return 150; } }, // ...other settings});Note that the first argument is the current width that we’re going to modify. Later arguments are immutable, and additional information can be used to decide whether the data should be modified.
For content-aware maximum width capping, see the Column widths guide.
Handsontable hooks
We refer to all callbacks as “Handsontable hooks” because, although they share some characteristics with events and middleware, they combine them both in a unique structure. You may already be familiar with the concept as we’re not the only ones that use the hooks convention.
Almost all before-prefixed Handsontable hooks let you return false and, therefore, block the execution of an action. It may be used for validation, rejecting operation by the outside service, or blocking our native algorithm and replace it with a custom implementation.
A great example for this is our integration with HyperFormula engine where creating a new row is only possible if the engine itself will allow it:
const hotSettings = ref({ beforeCreateRow(row, amount) { if (!hyperFormula.isItPossibleToAddRows(0, [row, amount])) { return false; } }, // ...other settings});The first argument may be modified and passed on through the Handsontable hooks that are next in the queue. This characteristic is shared between before and after hooks but is more common with the former. Before something happens, we can run the data through a pipeline of hooks that may modify or reject the operation. This provides many possibilities to extend the default Handsontable functionality and customize it for your application.
Commonly used hooks
Handsontable exposes hundreds of hooks. The ones below are the hooks you reach for most often, grouped by what they do. Unlike the source argument list further down this page, this grouping is about purpose, not about which callbacks receive a source argument.
Lifecycle — run code as the grid instance starts up.
Data changes — react to or alter the user’s edits. Returning false from beforeChange rejects the change.
When columns[].data is a function, the prop field in each change tuple is that accessor function. Use propToCol() to resolve the visual column index. See Binding to data: Identify changed columns in hooks.
Row and column structure (CRUD) — track rows and columns being added or removed. Each before variant can return false to block the operation.
Selection — respond to the user selecting cells.
Mouse interaction — respond to mouse actions on cells.
Keyboard interaction — respond to or override key presses. See the beforeKeyDown section below for a demo.
How validation affects hook order
When an edited cell has a validator, Handsontable runs the validation asynchronously. This applies to every validator, including one that calls its callback synchronously. It also applies to the built-in validators that cell types such as numeric, date, time, dropdown, and autocomplete use, so a cell can run a validator even without a custom validator function. The grid commits the change and fires the change-related hooks only after the validators for all edited cells resolve. As a result, the order in which hooks fire during an edit depends on whether the edited cell runs a validator.
When the edited cell runs no validator, the hooks fire synchronously:
When the edited cell runs a validator, the grid defers the change until validation finishes, so afterSelection fires before the change is committed:
beforeKeyDownbeforeChangebeforeValidateafterSelectionafterValidatebeforeChangeRenderafterChange
The exact sequence depends on how you confirm the edit. The lists above describe confirming an edit with Enter or Tab, which moves the selection and fires afterSelection while validation is still pending.
To act on a committed, validated value, use the afterChange or afterValidate hook. Don’t rely on a hook firing before or after afterSelection, because a validator changes that order. The beforeValidate and afterValidate hooks fire only when a validator is defined.
<script setup lang="ts">import { ref } from 'vue';import { HotTable } from '@handsontable/vue3';import { registerAllModules } from 'handsontable/registry';import type { GridSettings } from 'handsontable/settings';
registerAllModules();
const hotSettings = ref<GridSettings>({ data: [ ['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1', 'I1', 'J1', 'K1', 'L1'], ['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2', 'I2', 'J2', 'K2', 'L2'], ['A3', 'B3', 'C3', 'D3', 'E3', 'F3', 'G3', 'H3', 'I3', 'J3', 'K3', 'L3'], ['A4', 'B4', 'C4', 'D4', 'E4', 'F4', 'G4', 'H4', 'I4', 'J4', 'K4', 'L4'], ['A5', 'B5', 'C5', 'D5', 'E5', 'F5', 'G5', 'H5', 'I5', 'J5', 'K5', 'L5'], ['A6', 'B6', 'C6', 'D6', 'E6', 'F6', 'G6', 'H6', 'I6', 'J6', 'K6', 'L6'], ['A7', 'B7', 'C7', 'D7', 'E7', 'F7', 'G7', 'H7', 'I7', 'J7', 'K7', 'L7'], ['A8', 'B8', 'C8', 'D8', 'E8', 'F8', 'G8', 'H8', 'I8', 'J8', 'K8', 'L8'], ['A9', 'B9', 'C9', 'D9', 'E9', 'F9', 'G9', 'H9', 'I9', 'J9', 'K9', 'L9'], ['A10', 'B10', 'C10', 'D10', 'E10', 'F10', 'G10', 'H10', 'I10', 'J10', 'K10', 'L10'], ['A11', 'B11', 'C11', 'D11', 'E11', 'F11', 'G11', 'H11', 'I11', 'J11', 'K11', 'L11'], ['A12', 'B12', 'C12', 'D12', 'E12', 'F12', 'G12', 'H12', 'I12', 'J12', 'K12', 'L12'], ], autoWrapRow: true, autoWrapCol: true, height: 240, licenseKey: 'non-commercial-and-evaluation',});
function onFixedRowsChange(event: Event) { const checked = (event.target as HTMLInputElement).checked;
hotSettings.value = { ...hotSettings.value, fixedRowsTop: checked ? 2 : 0 };}
function onFixedColumnsChange(event: Event) { const checked = (event.target as HTMLInputElement).checked;
hotSettings.value = { ...hotSettings.value, fixedColumnsStart: checked ? 2 : 0 };}
function onRowHeadersChange(event: Event) { const checked = (event.target as HTMLInputElement).checked;
hotSettings.value = { ...hotSettings.value, rowHeaders: checked };}
function onColHeadersChange(event: Event) { const checked = (event.target as HTMLInputElement).checked;
hotSettings.value = { ...hotSettings.value, colHeaders: checked };}</script>
<template> <div id="example3" class="example-3-max-width"> <div class="example-controls-container"> <div class="controls"> <label> <input type="checkbox" @change="onFixedRowsChange" /> Add fixed rows </label> <br /> <label> <input type="checkbox" @change="onFixedColumnsChange" /> Add fixed columns </label> <br /> <label> <input type="checkbox" @change="onRowHeadersChange" /> Enable row headers </label> <br /> <label> <input type="checkbox" @change="onColHeadersChange" /> Enable column headers </label> </div> </div> <HotTable :settings="hotSettings" /> </div></template>
<style>.example-3-max-width { max-width: 440px;}</style>All available Handsontable hooks example
Note that some callbacks are checked on this page by default.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';// Register all Handsontable's modules.registerAllModules();const config = { data: [ ['', 'Tesla', 'Mazda', 'Mercedes', 'Mini', 'Mitsubishi'], ['2017', 0, 2941, 4303, 354, 5814], ['2018', 3, 2905, 2867, 412, 5284], ['2019', 4, 2517, 4822, 552, 6127], ['2020', 2, 2422, 5399, 776, 4151], ], minRows: 5, minCols: 6, height: 'auto', stretchH: 'all', minSpareRows: 1, autoWrapRow: true, colHeaders: true, contextMenu: true, autoWrapCol: true, dropdownMenu: true, filters: true, columnSorting: true, mergeCells: true, comments: true, manualColumnMove: true, manualRowMove: true, licenseKey: 'non-commercial-and-evaluation',};const example1Events = document.getElementById('example1_events');const hooksList = document.getElementById('hooksList');const hooks = Handsontable.hooks.getRegistered();hooks.forEach((hook) => { let checked = ''; if (hook === 'afterChange' || hook === 'afterSelection' || hook === 'afterCreateRow' || hook === 'afterRemoveRow' || hook === 'afterCreateCol' || hook === 'afterRemoveCol') { checked = 'checked'; } hooksList.innerHTML += `<li><label><input type="checkbox" ${checked} id="check_${hook}"> ${hook}</label></li>`; config[hook] = function () { log_events(hook, arguments); };});const start = new Date().getTime();let i = 0;let timer;/** * @param event * @param data */function log_events(event, data) { if (document.getElementById(`check_${event}`).checked) { const now = new Date().getTime(); const diff = now - start; let str; const vals = [i, `@${(diff / 1000).toFixed(3)}`, `[${event}]`]; for (let d = 0; d < data.length; d++) { try { str = JSON.stringify(data[d]); } catch (e) { str = data[d].toString(); } if (str === void 0) { continue; } if (str.length > 20) { str = typeof data[d] === 'object' ? `object: ${str.substr(0, 20)}...}` : str.toString(); } if (d < data.length - 1) { str += ','; } vals.push(str); } if (window.console) { console.log(i, `@${(diff / 1000).toFixed(3)}`, `[${event}]`, data); } const div = document.createElement('div'); const text = document.createTextNode(vals.join(' ')); div.appendChild(text); example1Events.appendChild(div); clearTimeout(timer); timer = setTimeout(() => { example1Events.scrollTop = example1Events.scrollHeight; }, 10); i++; }}const example1 = document.querySelector('#example1');new Handsontable(example1, config);document.querySelector('#check_select_all').addEventListener('click', function () { const state = this.checked; const inputs = document.querySelectorAll('#hooksList input[type=checkbox]'); Array.prototype.forEach.call(inputs, (input) => { input.checked = state; });});document.querySelector('#hooksList input[type=checkbox]').addEventListener('click', function () { if (!this.checked) { document.getElementById('check_select_all').checked = false; }});<div class="example-container"> <div class="example-table-container"> <div id="example1"></div> </div> <div id="example1_events"></div> <aside aria-label="Tip" class="starlight-aside starlight-aside--tip"> <p class="starlight-aside__title" aria-hidden="true">Tip</p> <div class="starlight-aside__content"><p>Open the Developer Tools Console tab to see each event details object.</p></div> </aside> <strong> Choose events to be logged:</strong> <ul id="hooksList"> <li> <label><input type="checkbox" id="check_select_all" />Select all</label> </li> </ul></div>Definition for source argument
It’s worth mentioning that some Handsontable hooks are triggered from the Handsontable core and some from the plugins. In some situations, it is helpful to know what triggered the callback. Did Handsontable trigger it, or was it triggered by external code or a user action? That’s why in crucial hooks, Handsontable delivers source as an argument informing you who triggered the action and providing detailed information about the source. Using the information retrieved in the source, you can create additional conditions.
source argument is optional. It takes the following values:
| Value | Description |
|---|---|
auto | Action triggered by Handsontable, and the reason for it is related directly to the settings applied to Handsontable. For example, afterCreateRow will be fired with this when minSpareRows will be greater than 0. |
edit | Action triggered by Handsontable after the data has been changed, e.g., after an edit or using setDataAtCell(), setDataAtRowProp(), or setSourceDataAtCell() methods. |
loadData | Action triggered by Handsontable after the loadData method has been called with the data property. |
updateData | Action triggered by Handsontable after the updateData method has been called; e.g., before or after a data change. |
updateSettings | Action triggered by updateSettings() when its settings object includes the data option. The beforeUpdateData and afterUpdateData hooks receive this source. |
populateFromArray | Action triggered by Handsontable after the populateFromArray() method has been called. |
spliceCol | Action triggered by Handsontable after the spliceCol() method has been called. |
spliceRow | Action triggered by Handsontable after the spliceRow() method has been called. |
timeValidate | Action triggered by Handsontable after the time validator has been called, e.g., after an edit. |
dateValidate | Action triggered by Handsontable after the date validator has been called, e.g., after an edit. |
validateCells | Action triggered by Handsontable after the validateCells() method has been called. |
Autofill.fill | Action triggered by the AutoFill plugin. |
ContextMenu.clearColumns | Action triggered by the ContextMenu plugin after the “Clear column” has been clicked. |
ContextMenu.columnLeft | Action triggered by the ContextMenu plugin after the “Insert column left” has been clicked. |
ContextMenu.columnRight | Action triggered by the ContextMenu plugin after the “Insert column right” has been clicked. |
ContextMenu.removeColumn | Action triggered by the ContextMenu plugin after the “Remove column” has been clicked. |
ContextMenu.removeRow | Action triggered by the ContextMenu plugin after the “Remove Row” has been clicked. |
ContextMenu.rowAbove | Action triggered by the ContextMenu plugin after the “Insert row above” has been clicked. |
ContextMenu.rowBelow | Action triggered by the ContextMenu plugin after the “Insert row below” has been clicked. |
CopyPaste.paste | Action triggered by the CopyPaste plugin after the value has been pasted. |
MergeCells | Action triggered by the MergeCells plugin when clearing the merged cells’ underlying cells. |
UndoRedo.redo | Action triggered by the UndoRedo plugin after the change has been redone. |
UndoRedo.undo | Action triggered by the UndoRedo plugin after the change has been undone. |
ColumnSummary.set | Action triggered by the ColumnSummary plugin after the calculation has been done. |
ColumnSummary.reset | Action triggered by the ColumnSummary plugin after the calculation has been reset. |
Prevent feedback loops
After initialization, updateSettings() uses updateData() internally when its settings object includes data. The beforeUpdateData and afterUpdateData hooks receive source === 'updateSettings'. The resulting afterChange hook receives changes === null and source === 'updateData'.
Calling updateSettings({ data }) inside afterChange triggers afterChange again. Check the source before replacing data to prevent a feedback loop. In this callback snippet, nextData contains the dataset supplied by your application:
hot.addHook('afterChange', (changes, source) => { if (source === 'updateData') { return; }
hot.updateSettings({ data: nextData, });});The beforeChange hook doesn’t run for whole-dataset replacements. Use beforeUpdateData to intercept data passed through updateSettings({ data }).
The same risk applies to a direct, single-cell write with setDataAtCell(): without a custom source, the write arrives in afterChange with source === 'edit' (see the source table above), indistinguishable from a user edit, so calling setDataAtCell() again from inside afterChange re-triggers the hook. Pass a custom source string and check for it:
hot.addHook('afterChange', (changes, source) => { if (source === 'sync') { return; }
// send `changes` to the server, then write the confirmed value back: hot.setDataAtCell(row, column, confirmedValue, 'sync');});Block changes with the beforeChange hook
Use beforeChange to validate or reject individual changes before they reach the grid. The hook receives the full batch of pending changes as an array of [row, prop, oldValue, newValue] tuples, so you can inspect and modify it in place.
The example below works on object data and cancels any change to the id property that isn’t a numeric string, using setDataAtRowProp()-style tuples:
hot.addHook('beforeChange', (changes, source) => { for (let i = changes.length - 1; i >= 0; i -= 1) { const [row, prop, oldValue, newValue] = changes[i];
if (prop === 'id' && !/^\d+$/.test(String(newValue))) { changes[i] = null; // cancel this one change } }
// return false; // cancels the entire batch instead});Setting an entry in changes to null cancels only that change; the rest of the batch still applies. Returning false from the hook cancels the whole batch instead. A change made through setDataAtRowProp() arrives with source === 'edit', the same as any other direct edit (see the source table above).
List of callbacks that operate on the source parameter:
The beforeKeyDown callback
The following demo uses beforeKeyDown callback to modify some key bindings:
- Pressing Delete or Backspace on a cell deletes the cell and shifts all cells beneath it in the column up resulting in the cursor, which doesn’t move, having the value previously beneath it, now in the current cell.
- Pressing Enter in a cell where the value remains unchanged pushes all the cells in the column beneath and including the current cell down one row. This results in a blank cell under the cursor which hasn’t moved.
<script setup lang="ts">import { ref, onMounted, useTemplateRef } from 'vue';import { HotTable } from '@handsontable/vue3';import { registerAllModules } from 'handsontable/registry';import type { GridSettings } from 'handsontable/settings';import type { CellChange } from 'handsontable/common';
registerAllModules();
const hotRef = useTemplateRef<InstanceType<typeof HotTable>>('hotRef');let lastChange: CellChange[] | null = null;
onMounted(() => { const hot = hotRef.value?.hotInstance;
hot?.updateSettings({ beforeKeyDown(e) { const selection = hot?.getSelected()?.[0];
if (!selection) return; if (selection[0] < 0 || selection[1] < 0) return;
// BACKSPACE or DELETE if (e.keyCode === 8 || e.keyCode === 46) { // remove data at cell, shift up hot.spliceCol(selection[1], selection[0], 1); e.preventDefault(); lastChange = null;
// block the default deletion behavior return false; }
// ENTER if (e.keyCode === 13) { // if last change affected a single cell and did not change its values if (lastChange && lastChange.length === 1 && lastChange[0][2] == lastChange[0][3]) { hot.spliceCol(selection[1], selection[0], 0, ''); // add new cell hot.selectCell(selection[0], selection[1]); lastChange = null;
// block the default Enter behavior return false; } }
lastChange = null; }, });});
const hotSettings = ref<GridSettings>({ data: [ ['Tesla', 2017, 'black', 'black'], ['Nissan', 2018, 'blue', 'blue'], ['Chrysler', 2019, 'yellow', 'black'], ['Volvo', 2020, 'yellow', 'gray'], ], colHeaders: true, rowHeaders: true, height: 'auto', minSpareRows: 1, beforeChange(changes) { lastChange = changes; }, autoWrapRow: true, autoWrapCol: true, licenseKey: 'non-commercial-and-evaluation',});</script>
<template> <div id="example2"> <HotTable ref="hotRef" :settings="hotSettings" /> </div></template>Related API reference
Core methods
Hooks
Related
- Configuration options — see how hooks interact with grid configuration at initialization.
- Saving data — a practical example of using the
afterChangehook to persist edits to a backend.