Sync rows to a Chart.js chart
In this tutorial, you will sync selected rows from a Handsontable grid to a Chart.js bar chart in real time. You will learn how to use afterSelectionEnd and afterDeselect hooks to read the current selection and update the chart without destroying and recreating it.
/* file: app.component.ts */import { Component, ViewChild, ElementRef, AfterViewInit, OnDestroy } from '@angular/core';import { GridSettings, HotTableComponent, HotTableModule } from '@handsontable/angular-wrapper';import { Chart, registerables, ChartConfiguration } from 'chart.js';
Chart.register(...registerables);
/* start:skip-in-preview */const data = [ { campaign: 'Spring Sale 2025', q1Budget: 12000, q1Revenue: 34500, q2Budget: 15000, q2Revenue: 41200 }, { campaign: 'Brand Awareness Q1', q1Budget: 8000, q1Revenue: 11300, q2Budget: 9500, q2Revenue: 13800 }, { campaign: 'Summer Promo', q1Budget: 5000, q1Revenue: 6200, q2Budget: 18000, q2Revenue: 52400 }, { campaign: 'Email Retargeting', q1Budget: 3500, q1Revenue: 9800, q2Budget: 4200, q2Revenue: 11600 }, { campaign: 'Influencer Campaign', q1Budget: 20000, q1Revenue: 38700, q2Budget: 22000, q2Revenue: 44100 }, { campaign: 'SEO Push Q2', q1Budget: 6000, q1Revenue: 7400, q2Budget: 9000, q2Revenue: 21300 }, { campaign: 'Holiday Countdown', q1Budget: 4500, q1Revenue: 5100, q2Budget: 25000, q2Revenue: 68900 }, { campaign: 'Brand Awareness Q3', q1Budget: 11000, q1Revenue: 16800, q2Budget: 13500, q2Revenue: 19400 },];/* end:skip-in-preview */
@Component({ standalone: true, imports: [HotTableModule], selector: 'example1-chartjs-sync', styles: [` #chart-canvas { max-height: 300px; margin-top: 16px; } `], template: ` <hot-table [data]="data" [settings]="gridSettings"></hot-table> <canvas #chartCanvas id="chart-canvas"></canvas> `,})export class AppComponent implements AfterViewInit, OnDestroy { @ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent; @ViewChild('chartCanvas') readonly canvasRef!: ElementRef<HTMLCanvasElement>;
readonly data = data;
private chart: Chart<'bar'> | null = null;
readonly gridSettings: GridSettings = { colHeaders: ['Campaign', 'Q1 Budget ($)', 'Q1 Revenue ($)', 'Q2 Budget ($)', 'Q2 Revenue ($)'], columns: [ { data: 'campaign', type: 'text', width: 200 }, { data: 'q1Budget', type: 'numeric', numericFormat: { pattern: '$0,0' }, width: 120 }, { data: 'q1Revenue', type: 'numeric', numericFormat: { pattern: '$0,0' }, width: 120 }, { data: 'q2Budget', type: 'numeric', numericFormat: { pattern: '$0,0' }, width: 120 }, { data: 'q2Revenue', type: 'numeric', numericFormat: { pattern: '$0,0' }, width: 120 }, ], rowHeaders: true, selectionMode: 'range', height: 'auto', width: '100%', autoWrapRow: true, afterSelectionEnd: () => { this.updateChart(); }, afterDeselect: () => { this.updateChart(); }, licenseKey: 'non-commercial-and-evaluation', };
ngAfterViewInit(): void { const config: ChartConfiguration<'bar'> = { type: 'bar', data: { labels: ['Select rows above to compare campaigns'], datasets: [ { label: 'Q1 Revenue ($)', data: [0], backgroundColor: 'rgba(54, 162, 235, 0.7)', borderColor: 'rgba(54, 162, 235, 1)', borderWidth: 1, }, { label: 'Q2 Revenue ($)', data: [0], backgroundColor: 'rgba(255, 99, 132, 0.7)', borderColor: 'rgba(255, 99, 132, 1)', borderWidth: 1, }, ], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top' }, title: { display: true, text: 'Campaign Revenue Comparison' }, }, scales: { y: { beginAtZero: true, ticks: { callback: (value) => `$${Number(value).toLocaleString()}`, }, }, }, }, };
this.chart = new Chart(this.canvasRef.nativeElement, config); }
ngOnDestroy(): void { this.chart?.destroy(); }
private updateChart(): void { const hot = this.hotTable?.hotInstance; const chart = this.chart;
if (!hot || !chart) { return; }
const selected = hot.getSelected();
if (!selected || selected.length === 0) { chart.data.labels = ['Select rows above to compare campaigns']; chart.data.datasets[0].data = [0]; chart.data.datasets[1].data = [0]; chart.update();
return; }
const rowSet = new Set<number>();
for (const [r1, , r2] of selected) { const minRow = Math.min(r1, r2); const maxRow = Math.max(r1, r2);
for (let row = minRow; row <= maxRow; row++) { rowSet.add(row); } }
const rows = [...rowSet].sort((a, b) => a - b); const labels: string[] = []; const q1Values: number[] = []; const q2Values: number[] = [];
for (const row of rows) { const rowData = hot.getDataAtRow(row);
labels.push(rowData[0] as string); q1Values.push(rowData[2] as number); q2Values.push(rowData[4] as number); }
chart.data.labels = labels; chart.data.datasets[0].data = q1Values; chart.data.datasets[1].data = q2Values; chart.update(); }}/* 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 */<div><example1-chartjs-sync></example1-chartjs-sync></div>Overview
Difficulty: Beginner
Time: ~15 minutes
Libraries: chart.js
This recipe shows how to connect a Handsontable grid to a Chart.js bar chart. When the user selects rows in the grid, the chart below updates immediately to show the corresponding data. No page reload or button click is required.
What You’ll Build
A grid showing marketing campaign data with two numeric columns per campaign (Q1 Revenue and Q2 Revenue). Selecting one or more rows in the grid updates a bar chart below the grid to compare the revenue figures for those campaigns. Deselecting all rows returns the chart to a placeholder state.
Before You Begin
Install Chart.js in your project:
npm install chart.jsIf you want to run the example without a build step, include Chart.js from a CDN instead:
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>Import Dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { Chart, registerables } from 'chart.js';registerAllModules();Chart.register(...registerables);registerAllModules()activates all Handsontable plugins and cell types.Chart.register(...registerables)registers all Chart.js components — scales, controllers, and elements — needed to render bar charts.Prepare the Data
const data = [{ campaign: 'Spring Sale 2025', q1Budget: 12000, q1Revenue: 34500, q2Budget: 15000, q2Revenue: 41200 },{ campaign: 'Brand Awareness Q1', q1Budget: 8000, q1Revenue: 11300, q2Budget: 9500, q2Revenue: 13800 },// ... more rows];The dataset uses an analytics domain: each row is a marketing campaign with budget and revenue figures for Q1 and Q2. The chart will display Q1 and Q2 revenue for selected rows.
Create the Chart Instance
const canvas = document.querySelector('#chart-canvas');const chart = new Chart(canvas, {type: 'bar',data: {labels: ['Select rows above to compare campaigns'],datasets: [{label: 'Q1 Revenue ($)',data: [0],backgroundColor: 'rgba(54, 162, 235, 0.7)',},{label: 'Q2 Revenue ($)',data: [0],backgroundColor: 'rgba(255, 99, 132, 0.7)',},],},options: {responsive: true,maintainAspectRatio: false,},});Create the Chart.js instance once — before initializing Handsontable. The initial state uses a placeholder label and zero values. You will update these values later using
chart.update()instead of destroying and recreating the chart on every selection change. This avoids expensive DOM teardown and prevents flickering.Write the
updateChartFunctionfunction updateChart(hot) {const selected = hot.getSelected();if (!selected || selected.length === 0) {chart.data.labels = ['Select rows above to compare campaigns'];chart.data.datasets[0].data = [0];chart.data.datasets[1].data = [0];chart.update();return;}const rowSet = new Set();for (const [r1, , r2] of selected) {const minRow = Math.min(r1, r2);const maxRow = Math.max(r1, r2);for (let row = minRow; row <= maxRow; row++) {rowSet.add(row);}}const rows = [...rowSet].sort((a, b) => a - b);const labels = [];const q1Values = [];const q2Values = [];for (const row of rows) {const rowData = hot.getDataAtRow(row);labels.push(rowData[0]);q1Values.push(rowData[2]);q2Values.push(rowData[4]);}chart.data.labels = labels;chart.data.datasets[0].data = q1Values;chart.data.datasets[1].data = q2Values;chart.update();}hot.getSelected()returns an array of[startRow, startCol, endRow, endCol]tuples — one entry per selection range. WithselectionMode: 'range', the logic handles arbitrary ranges correctly by extracting unique row indices.The function uses a
Setto collect unique row indices. This prevents duplicates when selection ranges overlap. Rows are then sorted so the chart bars appear in the same top-to-bottom order as the grid.hot.getDataAtRow(row)returns the current rendered row data as an array. Column indexes 0, 2, and 4 correspond to Campaign name, Q1 Revenue, and Q2 Revenue respectively.Assigning new arrays to
chart.data.labelsandchart.data.datasets[i].datathen callingchart.update()is the recommended Chart.js pattern for in-place updates. It avoids the cost of destroying and recreating the chart instance.Initialize Handsontable with
selectionMode: 'range'const hot = new Handsontable(container, {data,colHeaders: ['Campaign', 'Q1 Budget ($)', 'Q1 Revenue ($)', 'Q2 Budget ($)', 'Q2 Revenue ($)'],columns: [{ data: 'campaign', type: 'text', width: 200 },{ data: 'q1Budget', type: 'numeric', numericFormat: { pattern: '$0,0' }, width: 120 },{ data: 'q1Revenue', type: 'numeric', numericFormat: { pattern: '$0,0' }, width: 120 },{ data: 'q2Budget', type: 'numeric', numericFormat: { pattern: '$0,0' }, width: 120 },{ data: 'q2Revenue', type: 'numeric', numericFormat: { pattern: '$0,0' }, width: 120 },],rowHeaders: true,selectionMode: 'range',afterSelectionEnd() {updateChart(this);},afterDeselect() {updateChart(this);},licenseKey: 'non-commercial-and-evaluation',});selectionMode: 'range'allows selecting a contiguous range of cells. Click any cell in a row to include that row in the chart. Drag to select multiple rows at once.Two hooks drive the chart updates:
afterSelectionEnd— fires when the user finishes a selection. Thethiscontext inside the hook is the Handsontable instance, so passingthistoupdateChartprovides direct access to the grid’s data and selection state.afterDeselect— fires when all cells are deselected (e.g., pressingEscape). Without this hook, the chart would retain the last selection’s bars after the user clears the selection.
How It Works - Complete Flow
- Grid renders with 8 campaign rows. The chart shows a placeholder label.
- User clicks a row —
afterSelectionEndfires.hot.getSelected()returns one range tuple.updateChartreads that row’s campaign name and revenue values, then callschart.update(). The chart now shows one group of two bars. - User drags across another row —
afterSelectionEndfires again.hot.getSelected()returns the range tuple. TheSetdeduplicates rows. The chart now shows two groups of bars. - User presses Escape —
afterDeselectfires.hot.getSelected()returnsundefined.updateChartresets the chart to the placeholder state.
What you learned
- How to use
afterSelectionEndandafterDeselecthooks to react to grid selection changes. - How to read multi-range selections with
hot.getSelected()and collect unique row indices. - How to use
hot.getDataAtRow()to read current cell values. - How to update a Chart.js chart in place with
chart.data.labels,chart.data.datasets[i].data, andchart.update()— without destroying and recreating the chart instance. - How
selectionMode: 'range'enables range selection for comparison workflows.
Next steps
- Extend the chart to include more numeric columns (e.g., add Q1 Budget and Q2 Budget as additional datasets).
- Add a column filter so the chart only reflects visible rows.
- Replace the bar chart with a radar or line chart to show trends across more dimensions.
- Persist the selected row indices to a URL parameter so users can share a specific comparison view.