Built with ApexGrid, ApexCharts.js
From apex-grid 4.0 a row element is a slot, not a record. A sort or a scroll
hands the same element another account, so a chart created once inside a cell
keeps drawing the account it was created for. The fix is not to rebuild the
chart for every record, which is correct and expensive, but to keep one chart
per element for the element's whole life and re-point it with
updateSeries whenever the grid hands it a new record.
On apex-grid 4.1.0 or later, use the built-in column type.
type: 'chart' ships the element this recipe arrives at, with the accessible
name and the recycling rules already handled; see
Column Types. The rest of this page is
for a chart the column type does not cover, or for anyone on 4.0, and explains
why the column type works the way it does.
Everything below was measured in Chromium on apex-grid 4.0.1 and apexcharts 8.8.1: 1,000 rows in a 420px grid, each with a 12-point trend, scrolled from top to bottom in 240px steps and back. The demo above runs the same four approaches and counts from the live page.
npm install apex-grid apexcharts lit
What the harness establishes, before any of the reasoning:
- A chart element that builds its chart when it connects and destroys it when it disconnects, the lifecycle the Shadow DOM page shows, drew another row's data on 39 of 39 visible rows after one sort, without a single chart being rebuilt.
keyed, the grid docs' fix for stateful templates, is correct and built 3,011 charts for one scroll down and back, against 81 for a chart that updates in place.- During that scroll the grid disconnected chart elements 1,232 times, and 1,217 of those were moves: the element was connected again in the same task. A chart destroyed on every disconnect is rebuilt on every move.
- By default each ApexCharts sparkline is a
role="application"tab stop named "line chart with 1 data series", so a grid of them adds one tab stop per visible row, none of which names its row.
Do you need a chart at all?
Often not. The grid's own type: 'sparkline' column draws a 64 by 22 pixel
SVG path straight from each record, so it can never show the wrong row, and it
cost 74 ms of script for the whole scroll with no long tasks, the cheapest
result in the table below. Its limits are its option list, which has one
entry, showDelta, and its accessibility: the SVG is aria-hidden, so a
screen reader hears nothing for the trend.
| You need | Use |
|---|---|
| A trend line and a percentage change | The built-in sparkline column |
| Bars or columns, a second series, a colour per value, a target line, a tooltip, or a label a screen reader reads | An ApexCharts chart in a cell template, built as below |
| A chart of the grid's rows, not one per row | The grid's integrated chart, a different seam |
The built-in column has one trap of its own, covered on the real-time monitoring showcase: it redraws when the cell gets a new array, so pushing onto the existing one leaves the line where it was.
Why does every chart end up on the wrong row?
Because the element outlives the record. With row recycling on, which is the
4.0 default, a sort re-binds the rendered rows to the records now in view
instead of building new rows. Lit sets the new record on the same chart
element, and an element that only reads its data in connectedCallback
never looks again:
class RowChart extends HTMLElement {
connectedCallback() {
this.chart = new ApexCharts(this, sparkline(this.record.trend))
this.chart.render()
}
disconnectedCallback() {
this.chart.destroy()
}
}
// cellTemplate: (ctx) => html`<row-chart .record=${ctx.data}></row-chart>`
Measured, with each chart's drawn data read back from the chart, not from the element:
| Action | Rows drawing another row's data | Charts rebuilt |
|---|---|---|
| First paint | 0 of 39 | 39 built |
| One sort, no scroll | 39 of 39 | 0 |
| Scroll 40, 120 or 240 px | 0 | a few new rows only |
| Jump 1,200 px | 4 of 68 | |
| Scroll to the bottom | 63 of 63 |
The middle rows are why it ships. On those short scrolls the grid only added row elements (39 grew to 45) and every chart stayed right, so the page a developer checks while building it looks correct. The first sort, or the first long scroll, moves every chart onto another account, and each one is a plausible trend. In the demo, each chart prints the last value it drew beside a Revenue column that holds the true one: after one sort, Summitridge Foods reads €64.2K in the column and €2.7K on its chart.
The grid docs describe the general rule under
row recycling:
derive what you render from the context every time, or wrap stateful
templates in keyed. A chart is the stateful case.
Why not wrap it in keyed?
Because it is correct by rebuilding, and a chart is not cheap to rebuild.
keyed(ctx.data, ...) gives every record its own element, so every row that
comes into view creates a chart and every row that leaves destroys one. Turning
recycling off with rowRecycling = false does the same with less ceremony.
One scroll down and back over 1,000 rows, two runs each:
| Approach | Wrong rows | Charts built | Long tasks | Script time |
|---|---|---|---|---|
Built-in sparkline column | 0 | none | 0 | 74 ms |
| Build on connect | all | 1,298 | 5 to 7, 258 to 398 ms | 670 to 713 ms |
keyed | 0 | 3,011 | 14 to 17, 0.96 to 1.38 s | 1.17 to 1.55 s |
rowRecycling = false | 0 | 1,774 | 10 to 11, 711 to 757 ms | 888 to 956 ms |
| Destroy on every disconnect, data correct | 0 | 1,298 | 7, 379 ms | 651 ms |
| Update in place | 0 | 81 | 0 | 101 to 112 ms |
A long task is a single stretch of main-thread work over 50 ms, the point at
which a scroll stops tracking the pointer. The sort tells the same story at
smaller scale: keyed and rowRecycling = false each rebuilt every visible
chart on one sort (39 more), and update in place rebuilt none.
Why destroy only when it has really gone?
Because the virtualizer moves row elements far more often than it removes them. Across the scroll above, the chart elements saw 1,232 disconnects; 1,217 were followed by a reconnect in the same task, and 15 were real removals. The "destroy on every disconnect" row in the table has correct data and still built 1,298 charts, all of them for moves.
So the element checks a microtask later, when the move has finished:
disconnectedCallback() {
queueMicrotask(() => {
if (this.isConnected || !this.chart) return // moved, not removed
this.chart.destroy()
this.chart = null
})
}
The count of live charts stayed equal to the number of chart elements on the page throughout, 66 of 66 after the scroll back, so nothing leaks.
The element
One chart for the element's whole life, re-pointed when the grid hands it a record, destroyed only when it leaves the page, and named by itself:
import 'apex-grid/define'
import { html } from 'lit'
import ApexCharts from 'apexcharts'
class RowChart extends HTMLElement {
set record(r) {
this._record = r
this.setAttribute('aria-label', `${r.account}, revenue over 12 months`)
// Recycled: same element, new record. Re-point the chart it already has.
this.chart?.updateSeries([{ data: r.trend }], false)
}
get record() {
return this._record
}
connectedCallback() {
if (this.chart) return // a move, not a new row
this.setAttribute('role', 'img')
this.chart = new ApexCharts(this, {
chart: {
type: 'area', height: 30, width: 120,
sparkline: { enabled: true },
animations: { enabled: false },
accessibility: { enabled: false }, // the element carries the name
},
series: [{ data: this._record.trend }],
tooltip: { enabled: false },
})
this.chart.render()
}
disconnectedCallback() {
queueMicrotask(() => {
if (this.isConnected || !this.chart) return
this.chart.destroy()
this.chart = null
})
}
}
customElements.define('row-chart', RowChart)
grid.columns = [
{ key: 'account', headerText: 'Account' },
{ key: 'revenue', headerText: 'Revenue', type: 'number' },
{
key: 'trend',
headerText: 'Trend',
cellTemplate: (ctx) => html`<row-chart .record=${ctx.data}></row-chart>`,
},
]
Pass ctx.data, the record, rather than ctx.value: the label needs the
account name, and ctx.data is the field the context documents as staying the
same record for its whole life. Anything the chart draws, including a colour
chosen by value, has to be set again in the record setter, because a
recycled element arrives with the previous record's options.
The grid renders cells inside its own shadow root, which page CSS does not reach, so lay out anything you add next to the chart with inline styles or styles inside the element. The chart itself needs nothing: from 8.4.1 ApexCharts injects its stylesheet into the root it renders in.
What does a screen reader get?
From the default chart, a tab stop per row with a name that does not say which row. Measured on a single sparkline:
chart.accessibility | Role | Name | Tab stops |
|---|---|---|---|
| default | application | "line chart with 1 data series" | 1 |
keyboard: { enabled: false } | img | the same | 0 |
description: '...' | application | the description | 1 |
| both | img | the description | 0 |
enabled: false | none | none | 0 |
Inside a grid, which has its own keyboard model for moving between cells, a
chart that takes focus is in the way. Setting both description and
keyboard.enabled: false gives the right result, but the description then has
to change with every recycle, which means updateOptions instead of
updateSeries: that cost 2 to 6 long tasks (101 to 321 ms) per scroll where
updateSeries cost none. Taking the chart out of the tree with
enabled: false and putting role="img" and an aria-label on the element
itself costs nothing, and the label is a plain attribute the setter rewrites.
In the demo that left 0 tab stops and every visible chart named for its own
row.
When does none of this apply?
When the grid does not recycle. A grid whose rows all fit on one page never re-binds a row on scroll, though a sort still does, so the pattern is worth using anyway: it costs nothing over a chart built once. And when the built-in sparkline column says enough, use it: no chart instances, no lifecycle, and it is drawn from the record on every render.
It also does not apply to a chart of the whole grid. A chart that summarizes the rows in view, or follows the grid's sort and filter, is one chart beside the grid; see drill down from a chart into a grid for that seam.
Which plan covers this?
| What you need | Package | Plan |
|---|---|---|
The grid, cell templates, the built-in sparkline column | apex-grid | Community and up |
| The charts in the cells | apexcharts | Community and up |
Checked against the one entitlements table the pricing page is generated from: ApexGrid and ApexCharts.js both start at Community, and nothing here uses ApexGrid-Enterprise or an ApexCharts Premium feature. Community is free only for organizations under $2M USD in annual revenue, budget or funding, and a paid licence above it. Nothing in the family is open source. The pricing page has the matrix.
Row recycling in full: what re-binds, ctx.data, keyed and rowRecyclingSee the pieces running
Reference documentation
Frequently Asked Questions
Why do my charts show the wrong row after sorting an ApexGrid?
Because apex-grid 4.0 recycles rows: a sort re-binds the rendered row elements to the records now in view, so a chart element created for one account is handed another. A chart that reads its data only in connectedCallback keeps drawing the first account. Measured on apex-grid 4.0.1 with apexcharts 8.8.1, one sort left 39 of 39 visible rows drawing another row's data with no chart rebuilt, and a scroll to the bottom left 63 of 63. Re-point the chart in a property setter with updateSeries when the grid hands the element a new record.
Should I use keyed or rowRecycling = false for charts in grid cells?
Both are correct and both rebuild a chart every time a row shows a record it did not have. Over 1,000 rows scrolled to the bottom and back, keyed built 3,011 charts with 14 to 17 long tasks, and rowRecycling = false built 1,774 with 10 to 11. A chart element that keeps one chart and calls updateSeries built 81 with no long tasks, and on a sort it rebuilt none where the other two rebuilt every visible chart.
Why is my chart's disconnectedCallback called so often inside ApexGrid?
Because the virtualizer moves row elements far more often than it removes them. Across one scroll down and back over 1,000 rows, chart elements saw 1,232 disconnects, and 1,217 of them were reconnected in the same task. Destroying the chart on every disconnect rebuilt it 1,298 times. Check isConnected in a queueMicrotask callback and destroy only when the element has really left the page.
When is the built-in ApexGrid sparkline column enough?
When a trend line and a percentage change say everything. type: 'sparkline' draws a small SVG path straight from each record, so it cannot show the wrong row, and it was the cheapest approach measured, 74 ms of script for a full scroll with no long tasks. Its only option is showDelta and its SVG is aria-hidden, so bars, a second series, a colour per value, a tooltip or a label a screen reader reads all need an ApexCharts chart in a cell template.
How do I keep sparklines in a grid accessible?
By default each ApexCharts sparkline is a role="application" tab stop named "line chart with 1 data series", so a grid of them adds a tab stop per row and names none of them. chart.accessibility.keyboard.enabled false makes it an img with no tab stop, and description sets the name, but a name that changes on every recycle then needs updateOptions, which cost 2 to 6 long tasks per scroll. Setting accessibility.enabled false and putting role="img" and an aria-label on the chart's own element cost nothing and named every chart for its row.
Related
See every built-in column type
The ApexGrid column types demo shows the sparkline column beside the twelve other built-in renderers.