Row validation with error summary
In this tutorial, you will validate every row when the user clicks Submit and list all failures in an error summary outside the grid. You will learn how to highlight invalid cells with htInvalid and clear the error state automatically when the user corrects a cell.
Validation issues
import Handsontable from "handsontable/base";import { registerAllModules } from "handsontable/registry";registerAllModules();const COLUMN_LABELS = ["Item", "Quantity", "Unit price"];/** Column index -> returns `null` when valid, otherwise an error message. */const validationRules = { 0: (value) => { const text = String(value ?? "").trim(); return text.length > 0 ? null : "Item name is required"; }, 1: (value) => { if (value === null || value === "") { return "Quantity is required"; } const n = Number(value); return !Number.isNaN(n) && n > 0 && Number.isInteger(n) ? null : "Quantity must be a positive whole number"; }, 2: (value) => { if (value === null || value === "") { return "Unit price is required"; } const n = Number(value); return !Number.isNaN(n) && n > 0 ? null : "Unit price must be greater than 0"; },};const container = document.querySelector("#example1");const summaryList = document.querySelector("#validation-summary");const submitBtn = document.querySelector("#submit-orders");const invalidCells = new Set();let lastIssues = [];function cellKey(row, col) { return `${row}:${col}`;}function renderSummary(issues) { summaryList.innerHTML = issues .map((issue) => `<li>Row ${issue.row + 1}, ${COLUMN_LABELS[issue.col]}: ${issue.message}</li>`) .join("");}function clearHighlights(instance) { invalidCells.forEach((key) => { const [r, c] = key.split(":").map(Number); instance.removeCellMeta(r, c, "className"); instance.removeCellMeta(r, c, "title"); }); invalidCells.clear();}function applyHighlights(instance, issues) { issues.forEach((issue) => { instance.setCellMeta(issue.row, issue.col, "className", "htInvalid"); instance.setCellMeta(issue.row, issue.col, "title", issue.message); invalidCells.add(cellKey(issue.row, issue.col)); }); instance.render();}const hot = new Handsontable(container, { data: [ { item: "Widget A", qty: 2, price: 19.99 }, { item: "", qty: 1, price: 5 }, { item: "Gadget", qty: -1, price: 12 }, { item: "Cable", qty: 3, price: 0 }, ], colHeaders: COLUMN_LABELS, columns: [ { data: "item", type: "text", width: 180 }, { data: "qty", type: "numeric", width: 100 }, { data: "price", type: "numeric", numericFormat: { pattern: "0.00" }, width: 110 }, ], rowHeaders: true, height: "auto", width: "100%", licenseKey: "non-commercial-and-evaluation", afterRenderer(TD, row, col) { TD.style.backgroundColor = invalidCells.has(cellKey(row, col)) ? "var(--ht-cell-error-background-color, #ffe4e4)" : ""; }, afterChange(changes, source) { if (source === "loadData" || !changes) { return; } let touched = false; for (const change of changes) { const [row, prop] = change; const col = typeof prop === "string" ? this.propToCol(prop) : prop; const key = cellKey(row, col); if (!invalidCells.has(key)) { continue; } this.removeCellMeta(row, col, "className"); this.removeCellMeta(row, col, "title"); invalidCells.delete(key); lastIssues = lastIssues.filter((i) => !(i.row === row && i.col === col)); touched = true; } if (touched) { renderSummary(lastIssues); this.render(); } },});submitBtn.addEventListener("click", () => { clearHighlights(hot); const issues = []; for (let row = 0; row < hot.countRows(); row++) { for (let col = 0; col < hot.countCols(); col++) { const rule = validationRules[col]; if (!rule) { continue; } const value = hot.getDataAtCell(row, col); const message = rule(value); if (message !== null) { issues.push({ row, col, message }); } } } lastIssues = issues; renderSummary(issues); applyHighlights(hot, issues);});renderSummary([]);// eslint-disable-next-line no-unused-vars -- instance wired by event handlers and closuresvoid hot;import Handsontable from "handsontable/base";import { registerAllModules } from "handsontable/registry";
registerAllModules();
type ValidationRule = (value: unknown) => string | null;
const COLUMN_LABELS = ["Item", "Quantity", "Unit price"];
/** Column index -> returns `null` when valid, otherwise an error message. */const validationRules: Record<number, ValidationRule> = { 0: (value) => { const text = String(value ?? "").trim();
return text.length > 0 ? null : "Item name is required"; }, 1: (value) => { if (value === null || value === "") { return "Quantity is required"; }
const n = Number(value);
return !Number.isNaN(n) && n > 0 && Number.isInteger(n) ? null : "Quantity must be a positive whole number"; }, 2: (value) => { if (value === null || value === "") { return "Unit price is required"; }
const n = Number(value);
return !Number.isNaN(n) && n > 0 ? null : "Unit price must be greater than 0"; },};
const container = document.querySelector("#example1")!;const summaryList = document.querySelector("#validation-summary")! as HTMLUListElement;const submitBtn = document.querySelector("#submit-orders")! as HTMLButtonElement;
interface ValidationIssue { row: number; col: number; message: string;}
const invalidCells = new Set<string>();let lastIssues: ValidationIssue[] = [];
function cellKey(row: number, col: number): string { return `${row}:${col}`;}
function renderSummary(issues: ValidationIssue[]): void { summaryList.innerHTML = issues .map( (issue) => `<li>Row ${issue.row + 1}, ${COLUMN_LABELS[issue.col]}: ${issue.message}</li>`, ) .join("");}
function clearHighlights(instance: Handsontable): void { invalidCells.forEach((key) => { const [r, c] = key.split(":").map(Number);
instance.removeCellMeta(r, c, "className"); instance.removeCellMeta(r, c, "title"); }); invalidCells.clear();}
function applyHighlights(instance: Handsontable, issues: ValidationIssue[]): void { issues.forEach((issue) => { instance.setCellMeta(issue.row, issue.col, "className", "htInvalid"); instance.setCellMeta(issue.row, issue.col, "title", issue.message); invalidCells.add(cellKey(issue.row, issue.col)); }); instance.render();}
const hot = new Handsontable(container, { data: [ { item: "Widget A", qty: 2, price: 19.99 }, { item: "", qty: 1, price: 5 }, { item: "Gadget", qty: -1, price: 12 }, { item: "Cable", qty: 3, price: 0 }, ], colHeaders: COLUMN_LABELS, columns: [ { data: "item", type: "text", width: 180 }, { data: "qty", type: "numeric", width: 100 }, { data: "price", type: "numeric", numericFormat: { pattern: "0.00" }, width: 110 }, ], rowHeaders: true, height: "auto", width: "100%", licenseKey: "non-commercial-and-evaluation", afterRenderer(TD, row, col) { TD.style.backgroundColor = invalidCells.has(cellKey(row, col)) ? "var(--ht-cell-error-background-color, #ffe4e4)" : ""; }, afterChange(changes, source) { if (source === "loadData" || !changes) { return; }
let touched = false;
for (const change of changes) { const [row, prop] = change; const col = typeof prop === "string" ? this.propToCol(prop) : (prop as number); const key = cellKey(row, col);
if (!invalidCells.has(key)) { continue; }
this.removeCellMeta(row, col, "className"); this.removeCellMeta(row, col, "title"); invalidCells.delete(key); lastIssues = lastIssues.filter((i) => !(i.row === row && i.col === col)); touched = true; }
if (touched) { renderSummary(lastIssues); this.render(); } },});
submitBtn.addEventListener("click", () => { clearHighlights(hot);
const issues: ValidationIssue[] = [];
for (let row = 0; row < hot.countRows(); row++) { for (let col = 0; col < hot.countCols(); col++) { const rule = validationRules[col];
if (!rule) { continue; }
const value = hot.getDataAtCell(row, col); const message = rule(value);
if (message !== null) { issues.push({ row, col, message }); } } }
lastIssues = issues; renderSummary(issues); applyHighlights(hot, issues);});
renderSummary([]);
// eslint-disable-next-line no-unused-vars -- instance wired by event handlers and closuresvoid hot;<div class="row-validation-demo"> <div class="row-validation-demo__toolbar"> <button type="button" id="submit-orders">Submit orders</button> </div> <div id="example1"></div> <div class="row-validation-demo__summary" aria-live="polite"> <h3 class="row-validation-demo__summary-title">Validation issues</h3> <ul id="validation-summary" class="row-validation-demo__summary-list"></ul> </div></div>.row-validation-demo { display: flex; flex-direction: column; gap: var(--ht-gap, 12px); width: 100%;}
.row-validation-demo__toolbar { display: flex; flex-wrap: wrap; gap: var(--ht-gap, 8px); align-items: center; margin-bottom: 0.75rem;}
.row-validation-demo__toolbar 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: 4px; cursor: pointer; transition: color 0.15s, background-color 0.15s, border-color 0.15s;}
.row-validation-demo__toolbar button:not(:disabled):hover { color: var(--sl-color-white); background: var(--sl-color-gray-6);}
.row-validation-demo__toolbar button:focus-visible { outline: 1px solid var(--sl-color-accent); outline-offset: 1px;}
.row-validation-demo__toolbar 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;}
.row-validation-demo__summary { border: 1px solid var(--sl-color-gray-5); padding: 0.75rem 1rem; background: var(--sl-color-bg-nav);}
.row-validation-demo__summary-title { margin: 0 0 0.5rem; font-size: var(--sl-text-sm); font-weight: 600; color: var(--sl-color-text);}
.row-validation-demo__summary-list { margin: 0; padding-left: 1.25rem; font-size: var(--sl-text-sm); color: var(--sl-color-text);}
.row-validation-demo__summary-list:empty::before { content: 'No issues. Click Submit orders to validate.'; display: block; margin-left: -1.25rem; color: var(--sl-color-gray-3);}
.row-validation-demo #example1 .handsontable td.htInvalid { background-color: var(--ht-cell-error-background-color, #ffe4e4) !important;}Overview
Run validation for all rows when the user clicks Submit orders. Keep rules in a map from column index to a small function that returns null when the value is valid, or a string message when it is not. Collect every failure, show them in a list under the grid, and mark bad cells with htInvalid using setCellMeta plus render(). When the user edits a cell that was marked invalid, clear that cell’s highlight and update the summary.
Difficulty: Beginner
Time: ~15 minutes
Steps
- Rule map -
Record<number, (value) => string | null>keyed by visual column index. - Submit handler - Loop
rowfrom0tohot.countRows() - 1andcolover columns that have rules. Push{ row, col, message }for each failure. - Summary - Render the list as plain HTML (for example
Row 3, Unit price: must be greater than 0). - Highlight - For each issue,
hot.setCellMeta(row, col, 'className', 'htInvalid')and optionallysetCellMeta(..., 'title', message)for a native tooltip. Callhot.render(). - Reset between submits - Before validating again, remove
classNameandtitlefrom every cell you highlighted last time (removeCellMeta). afterChange- If the change touches a cell that is still in your invalid set, remove its meta, drop it from the summary, andrender().
Acceptance checks
- Invalid cells use the
htInvalidclass after Submit orders. - The summary lists every problem with row number, column label, and message.
- Fixing a highlighted cell removes that row from the summary and the red styling; Submit orders with a clean grid shows no issues.
Full example
The demo uses object data (item, qty, price) as a small order form. See the embedded example for the complete validationRules map and the Submit button wiring.
const validationRules: Record<number, (value: unknown) => string | null> = { 0: (value) => (String(value ?? '').trim() ? null : 'Item name is required'), // ...};
submitBtn.addEventListener('click', () => { // clear old highlights, scan all rows, renderSummary(issues), applyHighlights(hot, issues)});What you learned
- How to run synchronous validation over all rows by looping
hot.countRows()and applying a rule map keyed by visual column index. - How
hot.setCellMeta(row, col, 'className', 'htInvalid')highlights an invalid cell andremoveCellMetaclears it when the user corrects the value. - How to use
afterChangeto clear a cell’s invalid state as soon as the user fixes the value, rather than waiting for the next Submit. - How to render an error summary outside the grid that lists each problem with the row number, column label, and error message.
Next steps
- Combine this pattern with dependent dropdowns to validate that the selected child value is consistent with its parent.
- Explore cell validators for async, per-cell validation that runs on every edit instead of on Submit.