Recipe

Put a dashboard's view state in one URL

Copying the address bar should send what the reader is looking at, not the front door. Four products already hand you that state, in four different shapes, with four version numbers.

A chart and a grid captured into one link, including the restore that picks the wrong rowOpen in new tab

Built with ApexCharts.js, ApexGrid, ApexGantt, ApexStock

Someone finds something in your dashboard and wants to show a colleague. They copy the address bar, and what arrives is the dashboard's front door: no filter, no zoom, no selected row. The fix is not a screenshot. It is to treat "what I am looking at" as a value you can serialize, put in the link, and read back.

Every product in this family already hands you that value. The work is not capturing state, it is deciding what belongs in a URL and restoring it in an order that does not quietly lie to the reader.

npm install apexcharts apex-grid

What does each product actually give you?

Four products, four snapshot APIs, and they do not agree on names, shape, or how they fail. Measured against the installed versions:

ProductCaptureRestoreSchema versionOn bad input
ApexCharts 7.6.1chart.perspectives.capture().apply(token)view.vdecode() returns null
ApexGrid 3.5.0grid.getState()grid.setState(state)version: 1returns warnings, never throws
ApexGantt 3.18.1gantt.getState()gantt.setState(state)GANTT_STATE_VERSIONevery field optional, applies what it recognizes
ApexStock 0.5.1chart.getState()chart.setState(state)ApexStock.STATE_VERSIONmigrates older versions internally

Two things follow from that table and they shape everything below.

There are four version numbers, not one. Each product versions its own schema independently, and only ApexStock ships a migrator (ApexStock.migrateState()). A link you paste into a ticket today has to be readable after any one of the four bumps. So your envelope needs a version of its own, and a link it cannot read must degrade to the default dashboard rather than to a stack trace.

Not everything in a snapshot is in the snapshot. ApexStock captures a comparison's identity but not its price data, and re-emits comparisonRestoreNeeded with the names you have to re-supply. ApexGrid drops cell templates and sort comparers, re-binding them from your live column config. Restoring is therefore always "configure, then apply", never "apply".

Should this go in the URL or in local storage?

They answer different questions and the mistake is using one for both.

URLLocal storage
Answers"look at what I am looking at""put me back where I was"
Travels to another personyesno
Survives a schema bumponly if you version itsame
Size budgeta few kB, shared with everything elseeffectively unbounded
Right forfilter, zoom window, selected entity, active tabcolumn widths, pane sizes, which panel was collapsed

Column widths do not belong in a shared link. Nobody sends a colleague a URL to communicate that a column is 180 pixels wide, and every byte spent on it is a byte unavailable to the thing they did mean to send. ApexGantt already draws this line for you: persistState writes the whole GanttUiState to local storage on its own, so the URL only needs the slice you would say out loud.

Why is the chart token 3.9 kB before I have done anything?

This is the first thing that goes wrong, and it is invisible until a link stops working.

ApexCharts Perspectives is an opt-in feature, not part of the default bundle:

import ApexCharts from 'apexcharts'
import 'apexcharts/features/perspectives'

Capture a perspective from a freshly rendered line chart with nothing selected, nothing zoomed, and no annotations, and the encoded token is 3,895 characters. Measured on 7.6.1, on a four-point chart.

The reason is that capture() returns two slices, and only one of them is about the view. options defaults to serializing theme, xaxis, yaxis, title and subtitle, and it serializes them after defaults are resolved, so you get every axis tick style and crosshair fill your page never set. The view slice, which is the part that actually describes what the reader is looking at, is 440 characters of that total.

You configure the slice away:

const chart = new ApexCharts(el, {
  chart: {
    type: 'line',
    // The page already knows its own axis and theme config. Only the view
    // needs to travel.
    perspectives: { serializeOptions: [] },
  },
  series: [{ name: 'Revenue', data: [10, 41, 35, 51, 49] }],
})
serializeOptionsToken lengthZoom restored?
default (theme, xaxis, yaxis, title, subtitle)3,895 charsyes
[]439 charsyes

Same chart, same zoom, 8.9 times smaller, and applying the slim token still restores the window to { min: 2, max: 6 } exactly. Keep an entry in serializeOptions only when the reader can change it, which usually means theme and nothing else.

For scale: zooming the chart added 16 characters to the slim token. Almost the entire default token is configuration your page already has.

Why toURL() cannot carry a dashboard

chart.perspectives.toURL() exists and it is the right tool for exactly one chart on a page. For a dashboard it has two properties that disqualify it, both measured on 7.6.1:

It writes to a fixed hash key. The token lands at #apex=<token>, and the key is the literal string apex for every chart. Two charts calling toURL() produce two different tokens under the same key, so the second silently overwrites the first. There is one slot.

It overwrites whatever else is in the hash. Set location.hash to section-3, call toURL(), and the returned URL is #apex=.... The anchor is gone. If your page uses the fragment for anything (a deep link to a panel, a tab, a skip target) toURL() takes it.

So use the pieces underneath it, which are composable, and own the URL yourself:

// capture() -> encode() is what toURL() does internally, minus the hash write.
const chartToken = chart.perspectives.encode(chart.perspectives.capture())

One envelope, one parameter

Put everything in a single query parameter with a version you control. One parameter is easier to strip, log, and reason about than eight, and the version is what lets a future you reject a stale link cleanly.

const ENVELOPE_VERSION = 1

function captureDashboard({ chart, grid }) {
  return {
    v: ENVELOPE_VERSION,
    // The slice a reader would describe out loud.
    filter: { region: currentRegion, from: rangeStart, to: rangeEnd },
    chart: chart.perspectives.encode(chart.perspectives.capture()),
    grid: grid.getState(),
  }
}

function toShareURL(state) {
  const url = new URL(window.location.href)
  url.searchParams.set('view', btoa(JSON.stringify(state)))
  return url.toString()
}

A query parameter rather than the hash, deliberately: the hash never reaches the server, so a shared link cannot be server-rendered, logged, or given an accurate social preview. The trade is that a query parameter does reach your server logs, so do not put anything in it you would not want there.

Reading it back, with the version check doing real work:

function readShareURL() {
  const raw = new URL(window.location.href).searchParams.get('view')
  if (!raw) return null
  try {
    const state = JSON.parse(atob(raw))
    // An older link is not an error. It is a link to the default view.
    if (state.v !== ENVELOPE_VERSION) return null
    return state
  } catch {
    return null
  }
}

chart.perspectives.decode() follows the same contract: hand it a string that is not a token and it returns null rather than throwing, so a truncated link degrades instead of breaking the page.

What breaks first

A restored selection that points at the wrong row, silently.

This is the one to design against, because nothing reports it. ApexGrid captures selected, expanded and tree rows as a RowRef, which is either { id } or { index }. Which one you get depends on whether a rowId resolver is configured, and the difference only shows up after the data reloads.

Measured on apex-grid 3.5.0. Four rows, select A-2, capture, then reload the same four rows in a different order (an ordinary consequence of a server-side sort) and restore:

rowId configuredCapturedSelected after restoreWarnings
no{ index: 1 }A-1none
yes{ id: 'A-2' }A-2none

The first row of that table is the bug. The reader shared a link about one account and their colleague opened a link about a different account, with no error, no warning, and a perfectly plausible screen. A positional index round-trips within one session, which is exactly why this survives testing and fails in the wild.

One line prevents it:

// Now every captured row reference is stable across a reload.
grid.rowId = (row) => row.sku

The related trap: applied is not a success signal. Restore an id-based snapshot into a grid with no rowId resolver and the result is:

{
  applied: ['columns', 'sort', 'filter', 'quickFilter', 'selection', ...],
  skipped: [],
  warnings: ['selection: 1 of 1 row(s) could not be resolved'],
}

selection is listed as applied and zero rows are selected. The truth is only in warnings, so check that array rather than the one whose name suggests success:

const result = grid.setState(saved.grid)
if (result.warnings.length) {
  console.warn('[dashboard] partial restore', result.warnings)
}

In development, grid.setState(state, { strict: true }) turns the first warning into a throw, which is the right setting for a test and the wrong one for a persisted link.

Restoring before the data exists. Every one of these APIs reconciles against what is currently loaded. Restore a grid selection before rows arrive and the refs resolve to nothing; restore an ApexStock comparison and the instrument data is not in the snapshot at all. The order is fixed:

// 1. Configure. Columns, rowId, templates: everything the snapshot omits.
grid.columns = COLUMNS
grid.rowId = (row) => row.sku

// 2. Load the data the refs point into.
grid.data = await fetchRows(saved.filter)
await grid.updateComplete

// 3. Subscribe to anything that asks you for more, BEFORE applying.
stock.on('comparisonRestoreNeeded', ({ names }) => {
  names.forEach((name) => stock.addComparison({ name, data: cache[name] }))
})

// 4. Now apply.
const result = grid.setState(saved.grid)
chart.perspectives.apply(saved.chart, { animate: false })
stock.setState(saved.stock)

{ animate: false } on the chart because a restore is not a transition. The reader did not watch the first state, so animating into the second is time spent showing them something that was never true.

When to use something else

If the only thing a reader can change is a filter, skip all of this and put the filter in the URL as readable parameters. ?region=EMEA&from=2026-01-01 is shorter than any encoded token, survives every schema bump ever, can be hand-edited, and is the one form of this that a colleague can read without running it.

Reach for the snapshot APIs when the state is genuinely too structured to spell out: drawings on a stock chart, a multi-column filter with per-column operands, a tree expanded to an arbitrary depth. That is the point where hand-rolled parameters become a worse version of what the products already ship.

And if what you actually want is "put me back where I was", use local storage and persistState. It is not a link, so it does not have a link's constraints.

Which plan covers this?

ApexCharts.js and ApexGrid are both included on every plan, including Community, which is free for organizations under $2M USD in annual revenue, and grid.getState() and grid.setState() are not gated.

Perspectives is an ApexCharts Premium feature, so chart.perspectives needs the Premium plan or above. On Community you can still share a chart's view: put the handful of values you care about (the zoom window from the chart's own zoom events, the selected series) in your own envelope alongside the grid state. The envelope pattern in this recipe does not depend on Perspectives, it just gets shorter without it.

ApexGantt and ApexStock are Premium and up, so their getState() and setState() come with those products.

The re-entrancy guard this needs when the restored state is pushed back into a chart

See the pieces running

Reference documentation

Frequently Asked Questions

How do I put an ApexCharts view in a URL?

Capture it with chart.perspectives.capture() and encode it with .encode(), after importing the feature with import "apexcharts/features/perspectives". There is also a toURL() helper, but it writes to a fixed #apex hash key, so a second chart on the same page overwrites the first and any existing anchor in the fragment is lost. For a dashboard, encode each piece and own the URL yourself.

Why is my perspective token so large?

Because capture() serializes an options slice as well as the view, and it does so after defaults are resolved. Measured on ApexCharts 7.6.1, an empty line chart produces a 3,895 character token of which only 440 characters describe the view. Setting chart.perspectives.serializeOptions to an empty array brings the same token to 439 characters, and a zoom still restores exactly.

Why does my restored dashboard select the wrong row?

Because ApexGrid captures selected rows by positional index unless a rowId resolver is configured, and an index only round-trips within one session. Measured on apex-grid 3.5.0: select a row, reload the same rows in a different order, restore, and a different row is selected with no warning at all. Set grid.rowId to a stable field and the reference is captured by id instead.

How do I tell whether a state restore actually worked?

Read the warnings array on the SetStateResult, not applied. A slice appears in applied even when it resolved nothing: restoring an id-based selection into a grid with no rowId resolver reports selection as applied, selects zero rows, and puts the truth in warnings. Passing { strict: true } turns the first warning into a throw, which suits tests rather than persisted links.

Should view state go in the URL or in local storage?

The URL answers "look at what I am looking at" and local storage answers "put me back where I was". Filter, zoom window and selected entity belong in a link; column widths, pane sizes and collapsed panels do not, because nobody shares a link to communicate a column width. ApexGantt already separates them with its persistState option, which writes the full UI state to local storage on its own.

Related

See a shareable view running

The Perspectives demo captures and restores a chart view from an encoded token, which is the chart half of this envelope.

Get started