Dependent dropdowns
In this tutorial, you will drive a child column dropdown from a parent column using a dependency map. You will learn how to use afterChange, setCellMeta, and render to update dropdown source options dynamically when the user selects a value in the parent column.
import Handsontable from "handsontable/base";import { registerAllModules } from "handsontable/registry";registerAllModules();const CATEGORY_COL = 0;const SUBCATEGORY_COL = 1;/** Parent value -> allowed child dropdown labels */const dependencyMap = { Fruit: ["Apple", "Banana", "Orange"], Vegetable: ["Carrot", "Pea", "Broccoli"], Grain: ["Rice", "Wheat", "Oats"],};function optionsForCategory(category) { return dependencyMap[category] ?? [];}/* start:skip-in-preview */const data = [ ["Fruit", "Apple"], ["Vegetable", "Carrot"], ["Grain", ""],];/* end:skip-in-preview */const container = document.querySelector("#example1");// eslint-disable-next-line no-unused-vars -- instance kept for recipe previewconst hot = new Handsontable(container, { data, colHeaders: ["Category", "Subcategory"], columns: [ { type: "dropdown", source: Object.keys(dependencyMap) }, { type: "dropdown", source: optionsForCategory(String(data[0][CATEGORY_COL])) }, ], rowHeaders: true, height: 200, width: "100%", licenseKey: "non-commercial-and-evaluation", afterInit() { for (let row = 0; row < this.countRows(); row++) { const category = String(this.getDataAtCell(row, CATEGORY_COL) ?? ""); this.setCellMeta(row, SUBCATEGORY_COL, "source", optionsForCategory(category)); } this.render(); }, afterChange(changes, source) { if (source === "loadData" || !changes) { return; } for (const change of changes) { const [row, prop, oldVal, newVal] = change; if (prop !== CATEGORY_COL || oldVal === newVal) { continue; } const next = optionsForCategory(String(newVal)); this.setCellMeta(row, SUBCATEGORY_COL, "source", next); this.setDataAtCell(row, SUBCATEGORY_COL, next[0] ?? ""); } this.render(); },});import Handsontable from "handsontable/base";import { registerAllModules } from "handsontable/registry";
registerAllModules();
const CATEGORY_COL = 0;const SUBCATEGORY_COL = 1;
/** Parent value -> allowed child dropdown labels */const dependencyMap: Record<string, string[]> = { Fruit: ["Apple", "Banana", "Orange"], Vegetable: ["Carrot", "Pea", "Broccoli"], Grain: ["Rice", "Wheat", "Oats"],};
function optionsForCategory(category: string): string[] { return dependencyMap[category] ?? [];}
/* start:skip-in-preview */const data = [ ["Fruit", "Apple"], ["Vegetable", "Carrot"], ["Grain", ""],];/* end:skip-in-preview */
const container = document.querySelector("#example1")!;
// eslint-disable-next-line no-unused-vars -- instance kept for recipe previewconst hot = new Handsontable(container, { data, colHeaders: ["Category", "Subcategory"], columns: [ { type: "dropdown", source: Object.keys(dependencyMap) }, { type: "dropdown", source: optionsForCategory(String(data[0][CATEGORY_COL])) }, ], rowHeaders: true, height: 200, width: "100%", licenseKey: "non-commercial-and-evaluation", afterInit() { for (let row = 0; row < this.countRows(); row++) { const category = String(this.getDataAtCell(row, CATEGORY_COL) ?? ""); this.setCellMeta(row, SUBCATEGORY_COL, "source", optionsForCategory(category)); } this.render(); }, afterChange(changes, source) { if (source === "loadData" || !changes) { return; } for (const change of changes) { const [row, prop, oldVal, newVal] = change; if (prop !== CATEGORY_COL || oldVal === newVal) { continue; } const next = optionsForCategory(String(newVal)); this.setCellMeta(row, SUBCATEGORY_COL, "source", next); this.setDataAtCell(row, SUBCATEGORY_COL, next[0] ?? ""); } this.render(); },});Overview
Use a parent dropdown column and a child dropdown whose source list depends on the parent value (for example, Category and Subcategory). When the parent changes, you refresh the child’s source with setCellMeta, clear the child’s value, and call render() so the editor picks up the new options.
Difficulty: Beginner
Time: ~10 minutes
Steps
- Dependency map - Hold valid child labels per parent value (for example,
Fruit->Apple,Banana). afterChange- When the parent column changes, read the new parent value, computenewOptions, and runhot.setCellMeta(row, childCol, 'source', newOptions).- Reset the child - Call
hot.setDataAtCell(row, childCol, '')so the old subcategory does not stay when it is invalid for the new category. afterInit- Set each row’s childsourcefrom that row’s current parent so every row is consistent on load.hot.render()- Apply meta and view updates immediately after your changes.
Full example
The runnable demo below wires Category (column 0) to Subcategory (column 1).
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
const CATEGORY_COL = 0;const SUBCATEGORY_COL = 1;
const dependencyMap: Record<string, string[]> = { Fruit: ['Apple', 'Banana', 'Orange'], Vegetable: ['Carrot', 'Pea', 'Broccoli'], Grain: ['Rice', 'Wheat', 'Oats'],};
// ... see the embedded example for afterInit, columns, and afterChangeAcceptance checks
- Changing Category updates the Subcategory list for that row.
- Subcategory clears when Category changes.
- Each parent key in
dependencyMapshows only its mapped children in the dropdown.
What you learned
- How to use
afterChangeto detect when a parent column changes and update the child column’s dropdown source for that specific row. - How
setCellMeta(row, col, 'source', newOptions)replaces the dropdown options for a single cell without affecting other rows. - How
afterInitinitializes each row’s child dropdown from the current parent value so the grid is consistent on load. - Why calling
hot.render()aftersetCellMetais necessary to apply the updated source to the visible cells.
Next steps
- Extend the pattern to three-level dependent dropdowns (Region → Country → City) by chaining additional
afterChangehandlers. - Explore row validation with error summary to validate that the selected subcategory is always consistent with its parent.