Skip to content

In this tutorial, you will add, delete, and reorder rows in Handsontable using external toolbar buttons. You will learn how to wire buttons to the alter() API and the ManualRowMove plugin to give users full programmatic control over row order.

JavaScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
registerAllModules();
/* start:skip-in-preview */
const data = [
['Migrate auth service to OAuth 2.0', 'Alice Johnson', 'High', 'In Progress'],
['Write API documentation', 'Bob Smith', 'Normal', 'To Do'],
['Fix pagination bug on dashboard', 'Carol White', 'High', 'In Review'],
['Add CSV export feature', 'David Lee', 'Normal', 'To Do'],
['Upgrade React to v19', 'Eve Martinez', 'Low', 'Backlog'],
['Implement dark mode toggle', 'Frank Brown', 'Normal', 'In Progress'],
['Set up end-to-end test suite', 'Grace Kim', 'High', 'To Do'],
['Refactor database connection pool', 'Henry Wilson', 'Low', 'Backlog'],
];
/* end:skip-in-preview */
const container = document.querySelector('#example1');
const toolbar = document.createElement('div');
toolbar.classList.add('example-controls-container');
toolbar.innerHTML = `
<div class="controls">
<button id="btn-add-row" type="button">Add Row</button>
<button id="btn-delete-row" type="button">Delete Row</button>
<button id="btn-move-up" type="button">Move Up</button>
<button id="btn-move-down" type="button">Move Down</button>
</div>
`;
container.before(toolbar);
const hot = new Handsontable(container, {
data,
colHeaders: ['Task', 'Assignee', 'Priority', 'Status'],
rowHeaders: true,
height: 'auto',
width: '100%',
manualRowMove: true,
// Keep the grid selected when clicking toolbar buttons. Without this,
// Handsontable treats toolbar clicks as outside clicks and deselects,
// which clears selectedRow before the button's click handler runs.
outsideClickDeselects(target) {
return !toolbar.contains(target);
},
licenseKey: 'non-commercial-and-evaluation',
});
const btnAddRow = toolbar.querySelector('#btn-add-row');
const btnDeleteRow = toolbar.querySelector('#btn-delete-row');
const btnMoveUp = toolbar.querySelector('#btn-move-up');
const btnMoveDown = toolbar.querySelector('#btn-move-down');
let selectedRow = null;
function updateButtonStates() {
const hasSelection = selectedRow !== null;
const isFirst = selectedRow === 0;
const isLast = selectedRow === hot.countRows() - 1;
btnDeleteRow.disabled = !hasSelection;
btnMoveUp.disabled = !hasSelection || isFirst;
btnMoveDown.disabled = !hasSelection || isLast;
}
hot.addHook('afterSelectionEnd', (row, col, row2) => {
selectedRow = row === row2 ? Math.min(row, row2) : null;
updateButtonStates();
});
hot.addHook('afterDeselect', () => {
selectedRow = null;
updateButtonStates();
});
btnAddRow.addEventListener('click', () => {
hot.alter('insert_row_below', hot.countRows() - 1);
});
btnDeleteRow.addEventListener('click', () => {
const selected = hot.getSelected();
if (!selected) {
return;
}
const rowSet = new Set();
selected.forEach(([r1, , r2]) => {
const from = Math.min(r1, r2);
const to = Math.max(r1, r2);
for (let r = from; r <= to; r++) {
rowSet.add(r);
}
});
const rows = [...rowSet].sort((a, b) => b - a);
rows.forEach(row => hot.alter('remove_row', row, 1));
updateButtonStates();
});
btnMoveUp.addEventListener('click', () => {
if (selectedRow === null || selectedRow === 0) {
return;
}
hot.getPlugin('manualRowMove').moveRow(selectedRow, selectedRow - 1);
hot.render();
selectedRow -= 1;
hot.selectRows(selectedRow);
});
// ManualRowMove inserts BEFORE the target index, so moving row N down
// requires a target of N + 2 (the slot after the next row).
btnMoveDown.addEventListener('click', () => {
if (selectedRow === null || selectedRow === hot.countRows() - 1) {
return;
}
hot.getPlugin('manualRowMove').moveRow(selectedRow, selectedRow + 2);
hot.render();
selectedRow += 1;
hot.selectRows(selectedRow);
});
updateButtonStates();
TypeScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
registerAllModules();
/* start:skip-in-preview */
const data = [
['Migrate auth service to OAuth 2.0', 'Alice Johnson', 'High', 'In Progress'],
['Write API documentation', 'Bob Smith', 'Normal', 'To Do'],
['Fix pagination bug on dashboard', 'Carol White', 'High', 'In Review'],
['Add CSV export feature', 'David Lee', 'Normal', 'To Do'],
['Upgrade React to v19', 'Eve Martinez', 'Low', 'Backlog'],
['Implement dark mode toggle', 'Frank Brown', 'Normal', 'In Progress'],
['Set up end-to-end test suite', 'Grace Kim', 'High', 'To Do'],
['Refactor database connection pool', 'Henry Wilson', 'Low', 'Backlog'],
];
/* end:skip-in-preview */
const container = document.querySelector('#example1')!;
const toolbar = document.createElement('div');
toolbar.classList.add('example-controls-container');
toolbar.innerHTML = `
<div class="controls">
<button id="btn-add-row" type="button">Add Row</button>
<button id="btn-delete-row" type="button">Delete Row</button>
<button id="btn-move-up" type="button">Move Up</button>
<button id="btn-move-down" type="button">Move Down</button>
</div>
`;
container.before(toolbar);
const hot = new Handsontable(container, {
data,
colHeaders: ['Task', 'Assignee', 'Priority', 'Status'],
rowHeaders: true,
height: 'auto',
width: '100%',
manualRowMove: true,
outsideClickDeselects(target: HTMLElement) {
return !toolbar.contains(target);
},
licenseKey: 'non-commercial-and-evaluation',
});
const btnAddRow = toolbar.querySelector('#btn-add-row') as HTMLButtonElement;
const btnDeleteRow = toolbar.querySelector('#btn-delete-row') as HTMLButtonElement;
const btnMoveUp = toolbar.querySelector('#btn-move-up') as HTMLButtonElement;
const btnMoveDown = toolbar.querySelector('#btn-move-down') as HTMLButtonElement;
let selectedRow: number | null = null;
function updateButtonStates(): void {
const hasSelection = selectedRow !== null;
const isFirst = selectedRow === 0;
const isLast = selectedRow === hot.countRows() - 1;
btnDeleteRow.disabled = !hasSelection;
btnMoveUp.disabled = !hasSelection || isFirst;
btnMoveDown.disabled = !hasSelection || isLast;
}
hot.addHook('afterSelectionEnd', (row: number, _col: number, row2: number) => {
selectedRow = row === row2 ? Math.min(row, row2) : null;
updateButtonStates();
});
hot.addHook('afterDeselect', () => {
selectedRow = null;
updateButtonStates();
});
btnAddRow.addEventListener('click', () => {
hot.alter('insert_row_below', hot.countRows() - 1);
});
btnDeleteRow.addEventListener('click', () => {
const selected = hot.getSelected();
if (!selected) {
return;
}
const rowSet = new Set<number>();
selected.forEach(([r1, , r2]) => {
const from = Math.min(r1, r2);
const to = Math.max(r1, r2);
for (let r = from; r <= to; r++) {
rowSet.add(r);
}
});
const rows = [...rowSet].sort((a, b) => b - a);
rows.forEach(row => hot.alter('remove_row', row, 1));
updateButtonStates();
});
btnMoveUp.addEventListener('click', () => {
if (selectedRow === null || selectedRow === 0) {
return;
}
hot.getPlugin('manualRowMove').moveRow(selectedRow, selectedRow - 1);
hot.render();
selectedRow -= 1;
hot.selectRows(selectedRow);
});
// ManualRowMove inserts BEFORE the target index, so moving row N down
// requires a target of N + 2 (the slot after the next row).
btnMoveDown.addEventListener('click', () => {
if (selectedRow === null || selectedRow === hot.countRows() - 1) {
return;
}
hot.getPlugin('manualRowMove').moveRow(selectedRow, selectedRow + 2);
hot.render();
selectedRow += 1;
hot.selectRows(selectedRow);
});
updateButtonStates();

Overview

This recipe shows how to wire four external toolbar buttons — Add Row, Delete Row, Move Up, and Move Down — to Handsontable’s alter() API and the ManualRowMove plugin. This pattern is common in form-like grids where users manage an ordered list of items.

Difficulty: Beginner Time: ~15 minutes

What You’ll Build

A Handsontable grid with a toolbar that lets you:

  • Append a blank row at the bottom
  • Delete all rows covered by the current selection
  • Move the selected row one position up or down
  • Disable Move Up when the first row is selected, and Move Down when the last row is selected

Before you begin

You need a Handsontable instance. If you haven’t set one up yet, follow the Getting started guide first.

Make sure you have a container element with four buttons in your HTML:

<div class="row-toolbar">
<button id="btn-add-row">Add Row</button>
<button id="btn-delete-row">Delete Row</button>
<button id="btn-move-up">Move Up</button>
<button id="btn-move-down">Move Down</button>
</div>
<div id="example1"></div>
  1. Enable ManualRowMove and initialize Handsontable

    import Handsontable from 'handsontable/base';
    import { registerAllModules } from 'handsontable/registry';
    registerAllModules();
    const hot = new Handsontable(document.querySelector('#example1'), {
    data: myData,
    colHeaders: ['Task', 'Assignee', 'Priority', 'Status'],
    rowHeaders: true,
    height: 'auto',
    width: '100%',
    manualRowMove: true,
    licenseKey: 'non-commercial-and-evaluation',
    });

    What’s happening:

    • manualRowMove: true activates the ManualRowMove plugin. Without it, hot.getPlugin('manualRowMove').moveRow() does nothing.
    • registerAllModules() registers all built-in plugins so they are available by name.
  2. Track the selected row

    The Move Up and Move Down buttons act on a single row. Use a module-level variable to track which row is currently selected, and keep it in sync via two hooks:

    let selectedRow = null;
    hot.addHook('afterSelectionEnd', (row, col, row2) => {
    // Only track a single-row selection for move operations
    selectedRow = row === row2 ? Math.min(row, row2) : null;
    updateButtonStates();
    });
    hot.addHook('afterDeselect', () => {
    selectedRow = null;
    updateButtonStates();
    });

    What’s happening:

    • afterSelectionEnd fires after the user finishes selecting cells. The arguments row and row2 are the start and end rows of the selection rectangle.
    • When row === row2, a single row is selected — store it in selectedRow. When multiple rows are selected, set selectedRow to null to disable the move buttons (you can only move one row at a time).
    • afterDeselect fires when the grid loses focus or the selection is programmatically cleared. Reset selectedRow to null.
  3. Sync button disabled states

    Disable buttons that don’t apply to the current selection:

    function updateButtonStates() {
    const hasSelection = selectedRow !== null;
    const isFirst = selectedRow === 0;
    const isLast = selectedRow === hot.countRows() - 1;
    btnDeleteRow.disabled = !hasSelection;
    btnMoveUp.disabled = !hasSelection || isFirst;
    btnMoveDown.disabled = !hasSelection || isLast;
    }

    What’s happening:

    • Delete Row is disabled when nothing is selected.
    • Move Up is disabled on the first row — there is nowhere to go.
    • Move Down is disabled on the last row — there is nowhere to go.
    • Call updateButtonStates() after every hook callback and after every button action.
  4. Add Row button

    Append a blank row at the bottom:

    btnAddRow.addEventListener('click', () => {
    hot.alter('insert_row_below', hot.countRows() - 1);
    });

    What’s happening:

    • hot.alter('insert_row_below', index) inserts a row below index.
    • Passing hot.countRows() - 1 means “below the last row”, which appends to the end.
    • The new row is filled with null values — the grid treats missing values as empty cells.
  5. Delete Row button

    Remove every row touched by the current selection:

    btnDeleteRow.addEventListener('click', () => {
    const selected = hot.getSelected();
    if (!selected) {
    return;
    }
    // Collect all row indices covered by the selection
    const rowSet = new Set();
    selected.forEach(([r1, , r2]) => {
    const from = Math.min(r1, r2);
    const to = Math.max(r1, r2);
    for (let r = from; r <= to; r++) {
    rowSet.add(r);
    }
    });
    // Delete from bottom to top to keep earlier indices valid
    const rows = [...rowSet].sort((a, b) => b - a);
    rows.forEach(row => hot.alter('remove_row', row, 1));
    selectedRow = null;
    updateButtonStates();
    });

    What’s happening:

    • hot.getSelected() returns an array of [r1, c1, r2, c2] tuples — one per selection range (Handsontable supports non-contiguous multi-selection via Ctrl+Click).
    • For each tuple, collect every row index between r1 and r2 into a Set. The Set deduplicates rows that appear in overlapping ranges.
    • Sort the collected rows in descending order, then delete from bottom to top. If you delete row 2 first, the former row 3 becomes row 2, and all higher indices shift down — your loop would then delete the wrong rows. Deleting from the bottom up avoids this shift problem entirely.
    • Reset selectedRow and update button states after deleting.
  6. Move Up button

    Move the selected row one position earlier:

    btnMoveUp.addEventListener('click', () => {
    if (selectedRow === null || selectedRow === 0) {
    return;
    }
    hot.getPlugin('manualRowMove').moveRow(selectedRow, selectedRow - 1);
    hot.render();
    selectedRow -= 1;
    hot.selectRows(selectedRow);
    });

    What’s happening:

    • hot.getPlugin('manualRowMove').moveRow(from, to) moves the row at visual index from to visual index to. After the move, the row occupies position to.
    • hot.render() is required after moveRow() — the plugin modifies internal index mappings but does not repaint the grid automatically.
    • Update selectedRow to the new position and call hot.selectRows() to keep the row highlighted.
  7. Move Down button

    Move the selected row one position later:

    btnMoveDown.addEventListener('click', () => {
    if (selectedRow === null || selectedRow === hot.countRows() - 1) {
    return;
    }
    hot.getPlugin('manualRowMove').moveRow(selectedRow, selectedRow + 2);
    hot.render();
    selectedRow += 1;
    hot.selectRows(selectedRow);
    });

    What’s happening:

    • Moving a row down requires a target index of selectedRow + 2, not selectedRow + 1. The ManualRowMove target is the slot before which the row is inserted. To place row 3 after row 4, you must insert it before slot 5 — so the target is 3 + 2 = 5.
    • As with Move Up, call hot.render() and update selectedRow.

How It Works - Complete Flow

  1. Initial state - The grid renders with manualRowMove: true. All four buttons are disabled (no selection yet).
  2. User clicks a row - afterSelectionEnd fires, selectedRow is updated, and updateButtonStates() enables Delete Row and the appropriate Move Up / Move Down buttons.
  3. Add Row clicked - hot.alter('insert_row_below', lastIndex) appends a blank row. No selection change occurs.
  4. Delete Row clicked - All selected rows are collected into a Set, sorted descending, and removed one by one. The selection is cleared and buttons are reset.
  5. Move Up or Move Down clicked - moveRow() updates the internal row order, hot.render() repaints, and hot.selectRows() keeps the visual focus on the moved row.

What you learned

  • How to append rows with hot.alter('insert_row_below', index)
  • How to delete multiple selected rows safely by sorting indices descending before removing
  • How to reorder rows with hot.getPlugin('manualRowMove').moveRow(from, to) and why you must call hot.render() afterward
  • Why moving a row down requires target row + 2 (ManualRowMove inserts before the target slot)
  • How to sync external button states with afterSelectionEnd and afterDeselect hooks

Next steps

  • To restrict row reordering at runtime, listen to the beforeRowMove hook and return false to cancel specific moves.
  • To persist row order after a page reload, serialize the current row order with hot.getPlugin('manualRowMove').getOrderedIndexes() and restore it on init.
  • To trigger the same operations from a context menu instead of toolbar buttons, see Context menu.