Skip to content

Row parent-child

Reflect the parent-child relationship of your data, using the NestedRows plugin’s interactive UI elements such as expand and collapse buttons or an extended context menu.

Handsontable renders this structure as a tree grid. The same pattern is also called a master-detail view or grouping rows.

Quick setup

To enable the NestedRows plugin, set the nestedRows option to true.

const hotSettings = {
nestedRows: true,
};

Note that using all the functionalities provided by the plugin requires enabling the row headers and the Handsontable context menu. To do this set rowHeaders and contextMenu to true. The collapse / expand buttons are located in the row headers, and the row modification options add row, insert child, etc., are in the Context Menu.

Prepare the data source

The data source must have a specific structure to be used with the Nested Rows plugin.

The plugin requires the data source to be an array of objects. Each object in the array represents a single 0-level entry. 0-level refers to an entry, which is not a child of any other entry. If an entry has any child entries, they need to be declared again as an array of objects. To assign them to a row, create a __children property in the parent element. Child objects can define their own __children arrays, so you can nest rows to any depth. Handsontable does not impose a fixed nesting limit — the depth is determined by your data structure, and row header indentation grows with each level.

Here’s an example:

Vue
<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();
interface MusicRow {
category?: string;
artist?: string | null;
title?: string | null;
label?: string | null;
__children?: MusicRow[];
}
const sourceDataObject: MusicRow[] = [
{
category: 'Best Rock Performance',
artist: null,
title: null,
label: null,
__children: [
{
category: 'Major label releases',
artist: null,
title: null,
label: null,
__children: [
{
title: "Don't Wanna Fight",
artist: 'Alabama Shakes',
label: 'ATO Records',
},
{
title: 'What Kind Of Man',
artist: 'Florence & The Machine',
label: 'Republic',
},
{
title: 'Something From Nothing',
artist: 'Foo Fighters',
label: 'RCA Records',
},
],
},
{
category: 'Independent releases',
artist: null,
title: null,
label: null,
__children: [
{
title: 'Moaning Lisa Smile',
artist: 'Wolf Alice',
label: 'RCA Records/Dirty Hit',
},
{
title: "Ex's & Oh's",
artist: 'Elle King',
label: 'RCA Records',
},
],
},
],
},
{
category: 'Best Metal Performance',
__children: [
{
title: 'Cirice',
artist: 'Ghost',
label: 'Loma Vista Recordings',
},
{
title: 'Identity',
artist: 'August Burns Red',
label: 'Fearless Records',
},
{
title: '512',
artist: 'Lamb Of God',
label: 'Epic Records',
},
{
title: 'Thank You',
artist: 'Sevendust',
label: '7Bros Records',
},
{
title: 'Custer',
artist: 'Slipknot',
label: 'Roadrunner Records',
},
],
},
{
category: 'Best Rock Song',
__children: [
{
title: "Don't Wanna Fight",
artist: 'Alabama Shakes',
label: 'ATO Records',
},
{
title: "Ex's & Oh's",
artist: 'Elle King',
label: 'RCA Records',
},
{
title: 'Hold Back The River',
artist: 'James Bay',
label: 'Republic',
},
{
title: 'Lydia',
artist: 'Highly Suspect',
label: '300 Entertainment',
},
{
title: 'What Kind Of Man',
artist: 'Florence & The Machine',
label: 'Republic',
},
],
},
{
category: 'Best Rock Album',
__children: [
{
title: 'Drones',
artist: 'Muse',
label: 'Warner Bros. Records',
},
{
title: 'Chaos And The Calm',
artist: 'James Bay',
label: 'Republic',
},
{
title: 'Kintsugi',
artist: 'Death Cab For Cutie',
label: 'Atlantic',
},
{
title: 'Mister Asylum',
artist: 'Highly Suspect',
label: '300 Entertainment',
},
{
title: '.5: The Gray Chapter',
artist: 'Slipknot',
label: 'Roadrunner Records',
},
],
},
];
const hotSettings = ref<GridSettings>({
data: sourceDataObject,
preventOverflow: 'horizontal',
rowHeaders: true,
colHeaders: ['Category', 'Artist', 'Title', 'Album', 'Label'],
nestedRows: true,
contextMenu: true,
bindRowsWithHeaders: true,
autoWrapRow: true,
autoWrapCol: true,
height: 'auto',
licenseKey: 'non-commercial-and-evaluation',
afterInit() {
this.getPlugin('nestedRows').collapseParent(8);
},
});
</script>
<template>
<div id="example1">
<HotTable :settings="hotSettings" />
</div>
</template>

In the example above, we’ve created a data object consisting of 2016’s Grammy nominees of the “Rock” genre. Each 0-level entry declares a category. Under Best Rock Performance, nominees are grouped into subcategories (Major label releases and Independent releases) at the next level, with individual nominees nested one level deeper. The other categories use two levels: category and nominee, assigned under the __children properties.

Note that the first 0-level object in the array needs to have all columns defined to display the table properly. They can be declared as null or an empty string '', but they need to be defined. This is optional for the other objects.

Nested data vs. a flat array

A nested rows data source differs from a regular flat array of objects in one respect: child rows live inside their parent’s __children property, instead of being separate top-level elements. For example, the same three records can be represented either way:

// flat array -- three independent top-level rows
const flatData = [
{ category: 'Best Rock Performance', artist: null },
{ category: null, artist: 'Twenty One Pilots' },
{ category: null, artist: 'Coldplay' },
];
// nested rows -- two nominees grouped under one category
const nestedData = [
{
category: 'Best Rock Performance',
artist: null,
__children: [
{ category: null, artist: 'Twenty One Pilots' },
{ category: null, artist: 'Coldplay' },
],
},
];

getSourceData() returns this nested structure, __children arrays and all. getData() returns the flattened, currently visible rows — collapsed child rows are excluded. For more on how Handsontable relates source data to what’s displayed, see Understanding data and indexes.

User interface

The Nested Rows plugin’s user interface is placed in the row headers and the Handsontable’s context menu.

Row headers

Each parent row header contains a +/- button. It is used to collapse or expand its child rows.

The child row headers have a bigger indentation, to enable the user to clearly recognize the child and parent elements. In the example above, the Best Metal Performance category loads collapsed so you can see the expand/collapse controls right away.

Context Menu

The context menu has been extended with a few Nested Rows related options, such as:

  • Insert child row
  • Detach from parent

The “Insert row above” and “Insert row below” options were modified to work properly with the nested data structure.

Result

After completing this guide, your grid displays rows in a parent-child hierarchy with collapse and expand toggle buttons in row headers and context menu options for inserting and detaching child rows.

Collapse and expand rows from your code

The NestedRows plugin lets you collapse and expand parent rows from your own code, and tells you when it happens.

Methods

Get the plugin instance first, from your Handsontable instance:

const plugin = hot.getPlugin('nestedRows');
MethodWhat it does
collapseAll()Collapses every top-level parent
expandAll()Expands every parent, at every level
collapseParent(row)Collapses one parent
expandParent(row)Expands one parent
toggleParent(row)Collapses an expanded parent, or expands a collapsed one
getCollapsedParents()Physical indexes of the collapsed parents
isParentCollapsed(row)Checks one parent
isParent(row)Checks whether a row has children
getRowLevel(row)How deeply a row is nested. Top-level rows are at level 0
getRowParent(row)The parent of a row
countChildren(row)How many children a row has
expandToRow(row)Expands every ancestor, to reveal a hidden row
expandToLevel(level)Shows rows down to a nesting level, and collapses everything deeper

The example below calls four of them and prints what each one returns.

Vue
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { HotTable } from '@handsontable/vue3';
import { registerAllModules } from 'handsontable/registry';
import type { GridSettings } from 'handsontable/settings';
registerAllModules();
type TaskRow = {
task: string;
owner: string;
status: string;
__children?: TaskRow[];
};
const projectPlan: TaskRow[] = [
{
task: 'Marketing',
owner: 'Dana',
status: 'In progress',
__children: [
{
task: 'Website refresh',
owner: 'Ivy',
status: 'In progress',
__children: [
{ task: 'Copywriting', owner: 'Leo', status: 'Done' },
{ task: 'Visual design', owner: 'Mia', status: 'In review' },
],
},
{ task: 'Ad campaign', owner: 'Nico', status: 'Planned' },
],
},
{
task: 'Engineering',
owner: 'Sam',
status: 'In progress',
__children: [
{
task: 'API v2',
owner: 'Ravi',
status: 'In progress',
__children: [{ task: 'Auth endpoints', owner: 'Tess', status: 'Done' }],
},
{ task: 'Bug triage', owner: 'Kai', status: 'Planned' },
],
},
];
const hotRef = useTemplateRef<InstanceType<typeof HotTable>>('hotRef');
const output = ref('Click a button to call a method.');
// A plain const, so a status change beside the grid never triggers `updateSettings()`.
const hotSettings: GridSettings = {
data: projectPlan,
columns: [{ data: 'task' }, { data: 'owner' }, { data: 'status' }],
colHeaders: ['Task', 'Owner', 'Status'],
rowHeaders: true,
nestedRows: true,
contextMenu: true,
height: 'auto',
licenseKey: 'non-commercial-and-evaluation',
};
const getPlugin = () => hotRef.value?.hotInstance?.getPlugin('nestedRows');
const countRows = () => hotRef.value?.hotInstance?.countRows();
const collapseAll = () => {
getPlugin()?.collapseAll();
output.value = `collapseAll() -> ${countRows()} rows are visible now`;
};
const expandAll = () => {
getPlugin()?.expandAll();
output.value = `expandAll() -> ${countRows()} rows are visible now`;
};
// `toggleParent` takes a visual row index and returns `true` when the state changed.
const toggleFirst = () => {
const plugin = getPlugin();
const changed = plugin?.toggleParent(0);
output.value = `toggleParent(0) -> ${changed}, collapsed: ${plugin?.isParentCollapsed(0)}`;
};
// `getCollapsedParents` returns physical row indexes, because a parent collapsed inside another
// collapsed parent has no visual index at all.
const readState = () => {
const plugin = getPlugin();
output.value =
`getCollapsedParents() -> [${plugin?.getCollapsedParents()}]\n` +
`getRowLevel(0) -> ${plugin?.getRowLevel(0)}\n` +
`countChildren(0) -> ${plugin?.countChildren(0)}`;
};
</script>
<template>
<div id="example2">
<div class="example-controls-container">
<div class="controls">
<button class="button button--primary" @click="collapseAll">collapseAll()</button>
<button class="button button--primary" @click="expandAll">expandAll()</button>
<button class="button button--primary" @click="toggleFirst">toggleParent(0)</button>
<button class="button button--primary" @click="readState">Read the state</button>
</div>
<output class="console">{{ output }}</output>
</div>
<HotTable ref="hotRef" :settings="hotSettings" />
</div>
</template>

Which index type to pass

Collapsing a parent trims its children, which removes them from the grid. A trimmed row has no visual index at all, so the plugin uses two index types:

  • Methods that act on a row you can see take a visual row index. That covers collapseParent(), expandParent(), toggleParent(), isParentCollapsed(), isParent(), getRowLevel(), getRowParent(), and countChildren().
  • Methods that address a row the collapse itself hid take or return a physical row index. That covers getCollapsedParents() and expandToRow().

Convert between the two with toVisualRow() and toPhysicalRow().

Jump to a row inside a collapsed branch

This is where the two index types earn their keep. To reveal a row the user cannot see, you need expandToRow(), and you have to address that row by its physical index — a hidden row has no visual index to pass.

The example starts fully collapsed. Each button looks up a task’s physical row, expands whatever ancestors are hiding it, then selects it. Notice how the physical row stays the same while the visual row changes with whatever else is open.

Vue
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { HotTable } from '@handsontable/vue3';
import { registerAllModules } from 'handsontable/registry';
import type { GridSettings } from 'handsontable/settings';
registerAllModules();
type TaskRow = {
task: string;
owner: string;
status: string;
__children?: TaskRow[];
};
const projectPlan: TaskRow[] = [
{
task: 'Marketing',
owner: 'Dana',
status: 'In progress',
__children: [
{
task: 'Website refresh',
owner: 'Ivy',
status: 'In progress',
__children: [
{ task: 'Copywriting', owner: 'Leo', status: 'Done' },
{ task: 'Visual design', owner: 'Mia', status: 'In review' },
],
},
{ task: 'Ad campaign', owner: 'Nico', status: 'Planned' },
],
},
{
task: 'Engineering',
owner: 'Sam',
status: 'In progress',
__children: [
{
task: 'API v2',
owner: 'Ravi',
status: 'In progress',
__children: [{ task: 'Auth endpoints', owner: 'Tess', status: 'Done' }],
},
{ task: 'Bug triage', owner: 'Kai', status: 'Planned' },
],
},
];
// Physical row indexes follow the source data, depth first. Walk the tree once to map every task
// name to its physical row - that is the index `expandToRow` needs.
const physicalRowOf = new Map<string, number>();
let physicalRow = 0;
(function walk(rows: TaskRow[]) {
rows.forEach((row) => {
physicalRowOf.set(row.task, physicalRow);
physicalRow += 1;
walk(row.__children ?? []);
});
})(projectPlan);
const hotRef = useTemplateRef<InstanceType<typeof HotTable>>('hotRef');
const output = ref('Everything starts collapsed. Pick a task to jump to.');
const hotSettings: GridSettings = {
data: projectPlan,
columns: [{ data: 'task' }, { data: 'owner' }, { data: 'status' }],
colHeaders: ['Task', 'Owner', 'Status'],
rowHeaders: true,
nestedRows: true,
height: 'auto',
licenseKey: 'non-commercial-and-evaluation',
afterInit() {
this.getPlugin('nestedRows').collapseAll();
},
};
// Reveals a task that is currently hidden inside collapsed parents, then selects it.
const revealTask = (taskName: string) => {
const hot = hotRef.value?.hotInstance;
if (!hot) {
return;
}
const plugin = hot.getPlugin('nestedRows');
const row = physicalRowOf.get(taskName)!;
const wasHidden = hot.toVisualRow(row) === null;
// `expandToRow` takes a PHYSICAL index, because a hidden row has no visual index yet.
plugin.expandToRow(row);
const visualRow = hot.toVisualRow(row)!;
hot.selectCell(visualRow, 0);
output.value =
`"${taskName}" was ${wasHidden ? 'hidden' : 'already visible'}.\n` +
`physical row ${row} -> visual row ${visualRow}, nesting level ${plugin.getRowLevel(visualRow)}`;
};
const collapseEverything = () => {
const hot = hotRef.value?.hotInstance;
hot?.getPlugin('nestedRows').collapseAll();
output.value = `Collapsed again - ${hot?.countRows()} rows are visible.`;
};
</script>
<template>
<div id="example3">
<div class="example-controls-container">
<div class="controls">
<button class="button button--primary" @click="revealTask('Auth endpoints')">
Find "Auth endpoints"
</button>
<button class="button button--primary" @click="revealTask('Visual design')">
Find "Visual design"
</button>
<button class="button button--primary" @click="collapseEverything">
Collapse everything
</button>
</div>
<output class="console">{{ output }}</output>
</div>
<HotTable ref="hotRef" :settings="hotSettings" />
</div>
</template>

Hooks

Four hooks report every collapse and expand, whether it came from the row header button, the Enter shortcut, or one of the methods above:

They carry physical row indexes. Return false from either before hook to block the action:

const configurationOptions = {
// Stop the user from collapsing anything.
beforeRowCollapse() {
return false;
},
};

Save and restore the collapsed rows

The hooks carry physical indexes, which is what you want to store. To restore the state after replacing the data, collapse the deepest parents first: collapsing a parent hides its children, so a nested parent has to be collapsed while it is still visible.

const plugin = hot.getPlugin('nestedRows');
let saved = [];
hot.addHook('afterRowCollapse', (currentCollapsedRows, destinationCollapsedRows) => {
saved = destinationCollapsedRows;
});
hot.addHook('afterRowExpand', (currentCollapsedRows, destinationCollapsedRows) => {
saved = destinationCollapsedRows;
});
// Later, after replacing the whole data set:
hot.loadData(nextDataSet);
hot.batchExecution(() => {
[...saved]
.sort((a, b) => (plugin.getRowLevel(hot.toVisualRow(b)) ?? 0) - (plugin.getRowLevel(hot.toVisualRow(a)) ?? 0))
.forEach((physicalRow) => {
const visualRow = hot.toVisualRow(physicalRow);
if (visualRow !== null) {
plugin.collapseParent(visualRow);
}
});
}, true);

Notes

Known limitations

When you use the parent-child row structure, the following Handsontable features are not supported:

When the NestedRows plugin is enabled, the ManualRowMove plugin’s moveRows() method has no effect and logs a console warning. To move rows programmatically, use dragRows() instead.

Keyboard shortcuts

This header-focused shortcut works only when a row header is focused. Enable navigableHeaders: true to move focus onto headers with the arrow keys. For more details, see Keyboard navigation.

WindowsmacOSActionExcelSheets
EnterEnterCollapse or expand the row group

Related guides

Configuration options

Core methods

Plugin methods

Hooks

Plugins