Skip to content

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.

JavaScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
import { Chart, registerables } from 'chart.js';
registerAllModules();
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 */
const container = document.querySelector('#example1');
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)',
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) => `$${value.toLocaleString()}`,
},
},
},
},
});
function 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();
}
// eslint-disable-next-line no-unused-vars
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',
height: 'auto',
width: '100%',
autoWrapRow: true,
afterSelectionEnd() {
updateChart(this);
},
afterDeselect() {
updateChart(this);
},
licenseKey: 'non-commercial-and-evaluation',
});
TypeScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
import { Chart, registerables, ChartConfiguration } from 'chart.js';
registerAllModules();
Chart.register(...registerables);
/* start:skip-in-preview */
interface CampaignRow {
campaign: string;
q1Budget: number;
q1Revenue: number;
q2Budget: number;
q2Revenue: number;
}
const data: CampaignRow[] = [
{ 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 */
const container = document.querySelector<HTMLDivElement>('#example1')!;
const canvas = document.querySelector<HTMLCanvasElement>('#chart-canvas')!;
const chartConfig: 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()}`,
},
},
},
},
};
const chart = new Chart(canvas, chartConfig);
function updateChart(hot: Handsontable): void {
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();
}
// eslint-disable-next-line no-unused-vars
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',
height: 'auto',
width: '100%',
autoWrapRow: true,
afterSelectionEnd() {
updateChart(this);
},
afterDeselect() {
updateChart(this);
},
licenseKey: 'non-commercial-and-evaluation',
});
CSS
#chart-canvas {
max-height: 300px;
margin-top: 16px;
}
HTML
<div id="example1"></div>
<canvas id="chart-canvas"></canvas>

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:

Terminal window
npm install chart.js

If 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>
  1. 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.

  2. 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.

  3. 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.

  4. Write the updateChart Function

    function 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. With selectionMode: 'range', the logic handles arbitrary ranges correctly by extracting unique row indices.

    The function uses a Set to 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.labels and chart.data.datasets[i].data then calling chart.update() is the recommended Chart.js pattern for in-place updates. It avoids the cost of destroying and recreating the chart instance.

  5. 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. The this context inside the hook is the Handsontable instance, so passing this to updateChart provides direct access to the grid’s data and selection state.
    • afterDeselect — fires when all cells are deselected (e.g., pressing Escape). Without this hook, the chart would retain the last selection’s bars after the user clears the selection.

How It Works - Complete Flow

  1. Grid renders with 8 campaign rows. The chart shows a placeholder label.
  2. User clicks a rowafterSelectionEnd fires. hot.getSelected() returns one range tuple. updateChart reads that row’s campaign name and revenue values, then calls chart.update(). The chart now shows one group of two bars.
  3. User drags across another rowafterSelectionEnd fires again. hot.getSelected() returns the range tuple. The Set deduplicates rows. The chart now shows two groups of bars.
  4. User presses EscapeafterDeselect fires. hot.getSelected() returns undefined. updateChart resets the chart to the placeholder state.

What you learned

  • How to use afterSelectionEnd and afterDeselect hooks 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, and chart.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.