Recipe

Drill down from a chart into a grid

A reader clicks a bar and expects the table below to show the rows behind it. The chart gives you an index, not a value, and that gap is where most implementations go wrong.

Crossfilter DashboardOpen in new tab

Built with ApexCharts.js, ApexGrid

Clicking a bar to filter a table below it is the most common interaction in any analytics screen. ApexCharts gives you a click handler and ApexGrid takes rows, so the wiring is short. What makes it go wrong is that the chart reports a position, not a value, and the mapping between them is yours to keep correct.

Which event fires when a data point is clicked?

chart.events.dataPointSelection. Its third argument is the useful one:

const chart = new ApexCharts(el, {
  chart: {
    type: 'bar',
    events: {
      dataPointSelection(event, chartContext, config) {
        config.dataPointIndex   // 2      position along the category axis
        config.seriesIndex      // 0      which series
        config.selectedDataPoints // [[2]]  everything currently selected
      },
    },
  },
  series: [{ name: 'Revenue', data: [120, 340, 210] }],
  xaxis: { categories: ['SaaS', 'Retail', 'Finance'] },
})

There is no category and no label in that payload. To get "Finance" you index your own array:

const INDUSTRIES = ['SaaS', 'Retail', 'Finance']

dataPointSelection(event, ctx, config) {
  const industry = INDUSTRIES[config.dataPointIndex]
  applyFilter(industry)
}

Why the category array is load-bearing

Because the index means nothing without it. The most common way to break this is a reasonable-looking display change:

// Sorts the bars biggest-first for readability...
const sorted = [...rows].sort((a, b) => b.revenue - a.revenue)
series = [{ data: sorted.map((r) => r.revenue) }]
// ...and now dataPointIndex 0 is whichever industry happens to lead today.

Nothing throws. The chart looks better. Clicks filter to the wrong industry, and only for some datasets. If you sort for display, sort the category array in the same operation and read the label out of the sorted array, never out of the original.

The event fires on deselect as well

dataPointSelection fires when a point is selected and when it is deselected. A handler that only ever assigns a filter can never clear one, so the reader gets stuck on their first click. Toggle instead:

dataPointSelection(event, ctx, config) {
  const industry = INDUSTRIES[config.dataPointIndex]
  // Second click on the same bar clears the filter.
  filter.industry = filter.industry === industry ? null : industry
  applyFilter()
}

If you need to know which way it went rather than inferring it, config.selectedDataPoints holds the full current selection.

Handing rows to the grid

ApexGrid takes an array. Assigning data replaces the rows it renders:

function applyFilter() {
  const rows = ACCOUNTS.filter((r) => !filter.industry || r.industry === filter.industry)
  grid.data = rows
}

That is deliberately not the grid's own filtering API. Two filtering mechanisms against one reader intent means two things that have to agree, and they eventually will not. Use the grid's own filtering when the reader needs to filter inside the grid as well, and treat the chart selection as an outer filter applied before rows ever reach it.

What breaks first

The chart filtering itself away. If the chart aggregates over the same filtered rows as the grid, clicking a bar leaves a chart with one bar in it, and the control the reader just used is gone. Each filter source has to aggregate over rows filtered by everything except itself:

// The grid sees every active filter.
const gridRows = ACCOUNTS.filter(matchesEverything)

// The chart sees every active filter EXCEPT its own.
const chartRows = ACCOUNTS.filter((r) => !filter.state || r.state === filter.state)

The feedback loop, if you push state back to the chart. Restoring a selection in code with toggleDataPointSelection makes the chart emit dataPointSelection again, and that event is indistinguishable from a real click. Measured on ApexCharts 7.1.0, the order is:

toggleDataPointSelection(0, 1) called
  -> dataPointSelection fires   (dataPointIndex = 1)
  -> toggleDataPointSelection returns

The event arrives synchronously, before your call returns, so the loop starts inside the function that was meant to end it. One flag stops it:

let applying = false

function applyFilter() {
  if (applying) return
  applying = true
  try {
    grid.data = ACCOUNTS.filter(matches)
    chart.toggleDataPointSelection(0, indexOf(filter.industry))  // re-entrant
  } finally {
    applying = false
  }
}

You only need this if you restore chart selection programmatically. If the chart is the only thing that ever sets the filter, it never comes up.

For the record, one thing that is safe and reads as though it should not be: calling chart.updateSeries() synchronously from inside dataPointSelection is fine. It does not throw or corrupt the chart, so it needs no deferral.

Which plan covers this?

ApexCharts.js and ApexGrid are both included on every plan, including Community, which is free for organizations under $2M in annual revenue. dataPointSelection and grid data binding are not gated features.

See this seam inside a full three-product dashboard

See the pieces running

Reference documentation

Frequently Asked Questions

How do I detect which bar was clicked in ApexCharts?

Use the chart.events.dataPointSelection handler. Its third argument carries dataPointIndex (the position along the category axis) and seriesIndex (which series). Neither is the category label, so you have to map the index back to your own data yourself.

Why does dataPointSelection give an index instead of a value?

Because a chart series is an array of numbers with a parallel array of categories. The chart knows position, not meaning. That makes the category array load-bearing: if you sort the bars by value for display, every index silently repoints to a different label.

Does clicking a chart point fire on deselect too?

Yes. dataPointSelection fires on both selection and deselection, so a handler that only ever sets a filter will never clear it. Compare the incoming value against the current filter and toggle.

Should the grid filter itself, or should I hand it filtered rows?

Hand it filtered rows when the chart is the only filter UI, which keeps one definition of the current selection. Use the grid's own filtering when the reader also needs to filter inside the grid, and treat the chart selection as a separate outer filter applied before the data reaches the grid.

Related

See it in a full dashboard

This recipe is one seam of the sales analytics showcase, which cross-filters charts, a grid and a map.

Get started