Recipe

Put a live chart in every grid row

Since apex-grid 4.0 a row element is a slot, not a record. Build a chart in it once and one sort moves every chart onto another account; rebuild it per record and one scroll builds 3,011 charts. Keep one chart per element and re-point it instead.

A 1,000-row grid with a chart in every row, drawn four ways, with a live readoutOpen in new tab
Rebuilds this page in your assistant. It reads current API guidance from our MCP server rather than from its training data.

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 needUse
A trend line and a percentage changeThe 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 readsAn ApexCharts chart in a cell template, built as below
A chart of the grid's rows, not one per rowThe 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:

ActionRows drawing another row's dataCharts rebuilt
First paint0 of 3939 built
One sort, no scroll39 of 390
Scroll 40, 120 or 240 px0a few new rows only
Jump 1,200 px4 of 68
Scroll to the bottom63 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:

ApproachWrong rowsCharts builtLong tasksScript time
Built-in sparkline column0none074 ms
Build on connectall1,2985 to 7, 258 to 398 ms670 to 713 ms
keyed03,01114 to 17, 0.96 to 1.38 s1.17 to 1.55 s
rowRecycling = false01,77410 to 11, 711 to 757 ms888 to 956 ms
Destroy on every disconnect, data correct01,2987, 379 ms651 ms
Update in place0810101 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.accessibilityRoleNameTab stops
defaultapplication"line chart with 1 data series"1
keyboard: { enabled: false }imgthe same0
description: '...'applicationthe description1
bothimgthe description0
enabled: falsenonenone0

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 needPackagePlan
The grid, cell templates, the built-in sparkline columnapex-gridCommunity and up
The charts in the cellsapexchartsCommunity 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 rowRecycling

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

Get started