Skip to content

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.

TypeScript
/* file: app.component.ts */
import { Component, ViewChild, ChangeDetectorRef, inject } from '@angular/core';
import { GridSettings, HotTableComponent, HotTableModule } from '@handsontable/angular-wrapper';
import { RowObject } from 'handsontable/common';
const COLUMN_LABELS = ['Item', 'Quantity', 'Unit price'];
/** Column index -> returns `null` when valid, otherwise an error message. */
const validationRules: Record<number, (value: unknown) => string | null> = {
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';
},
};
function cellKey(row: number, col: number): string {
return `${row}:${col}`;
}
@Component({
standalone: true,
imports: [HotTableModule],
selector: 'example1-row-validation-error-summary',
template: `
<div class="row-validation-demo">
<div class="row-validation-demo__toolbar">
<button type="button" (click)="onSubmit()">
Submit orders
</button>
</div>
<hot-table [data]="data" [settings]="gridSettings"></hot-table>
<div class="row-validation-demo__summary" aria-live="polite">
<h3 class="row-validation-demo__summary-title">Validation issues</h3>
<ul class="row-validation-demo__summary-list">
@for (issue of issues; track $index) {
<li>Row {{ issue.row + 1 }}, {{ columnLabels[issue.col] }}: {{ issue.message }}</li>
}
</ul>
</div>
</div>
`,
})
export class AppComponent {
@ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent;
readonly columnLabels = COLUMN_LABELS;
private readonly cdr = inject(ChangeDetectorRef);
invalidCells = new Set<string>();
issues: { row: number; col: number; message: string }[] = [];
readonly 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 },
];
readonly gridSettings: GridSettings = {
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%',
afterRenderer: (TD: HTMLTableCellElement, row: number, col: number) => {
TD.style.backgroundColor = this.invalidCells.has(cellKey(row, col))
? 'var(--ht-cell-error-background-color, #ffe4e4)'
: '';
},
afterChange: (changes, source) => {
if (source === 'loadData' || !changes) {
return;
}
let touched = false;
const hot = this.hotTable?.hotInstance;
if (!hot) {
return;
}
for (const change of changes) {
const [row, prop] = change;
const col = (typeof prop === 'string' ? hot.propToCol(prop) : prop) as number;
const key = cellKey(row, col);
if (!this.invalidCells.has(key)) {
continue;
}
hot.removeCellMeta(row, col, 'className');
hot.removeCellMeta(row, col, 'title');
this.invalidCells.delete(key);
this.issues = this.issues.filter((i) => !(i.row === row && i.col === col));
touched = true;
}
if (touched) {
this.cdr.detectChanges();
hot.render();
}
},
};
onSubmit(): void {
const hot = this.hotTable?.hotInstance;
if (!hot) {
return;
}
// Clear previous highlights
this.invalidCells.forEach((key) => {
const [r, c] = key.split(':').map(Number);
hot.removeCellMeta(r, c, 'className');
hot.removeCellMeta(r, c, 'title');
});
this.invalidCells.clear();
const newIssues: { row: number; col: number; message: string }[] = [];
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) {
newIssues.push({ row, col, message });
}
}
}
this.issues = newIssues;
newIssues.forEach((issue) => {
hot.setCellMeta(issue.row, issue.col, 'className', 'htInvalid');
hot.setCellMeta(issue.row, issue.col, 'title', issue.message);
this.invalidCells.add(cellKey(issue.row, issue.col));
});
this.cdr.detectChanges();
hot.render();
}
}
/* end-file */
/* file: app.config.ts */
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { registerAllModules } from 'handsontable/registry';
import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';
registerAllModules();
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
{
provide: HOT_GLOBAL_CONFIG,
useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig,
},
],
};
/* end-file */
HTML
<div><example1-row-validation-error-summary></example1-row-validation-error-summary></div>
CSS
.row-validation-demo {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.row-validation-demo__toolbar {
display: flex;
flex-wrap: wrap;
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 .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

  1. Rule map - Record<number, (value) => string | null> keyed by visual column index.
  2. Submit handler - Loop row from 0 to hot.countRows() - 1 and col over columns that have rules. Push { row, col, message } for each failure.
  3. Summary - Render the list as plain HTML (for example Row 3, Unit price: must be greater than 0).
  4. Highlight - For each issue, hot.setCellMeta(row, col, 'className', 'htInvalid') and optionally setCellMeta(..., 'title', message) for a native tooltip. Call hot.render().
  5. Reset between submits - Before validating again, remove className and title from every cell you highlighted last time (removeCellMeta).
  6. afterChange - If the change touches a cell that is still in your invalid set, remove its meta, drop it from the summary, and render().

Acceptance checks

  • Invalid cells use the htInvalid class 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 and removeCellMeta clears it when the user corrects the value.
  • How to use afterChange to 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.