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:
| Product | Capture | Restore | Schema version | On bad input |
|---|---|---|---|---|
| ApexCharts 7.6.1 | chart.perspectives.capture() | .apply(token) | view.v | decode() returns null |
| ApexGrid 3.5.0 | grid.getState() | grid.setState(state) | version: 1 | returns warnings, never throws |
| ApexGantt 3.18.1 | gantt.getState() | gantt.setState(state) | GANTT_STATE_VERSION | every field optional, applies what it recognizes |
| ApexStock 0.5.1 | chart.getState() | chart.setState(state) | ApexStock.STATE_VERSION | migrates 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.
| URL | Local storage | |
|---|---|---|
| Answers | "look at what I am looking at" | "put me back where I was" |
| Travels to another person | yes | no |
| Survives a schema bump | only if you version it | same |
| Size budget | a few kB, shared with everything else | effectively unbounded |
| Right for | filter, zoom window, selected entity, active tab | column 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] }],
})
serializeOptions | Token length | Zoom restored? |
|---|---|---|
default (theme, xaxis, yaxis, title, subtitle) | 3,895 chars | yes |
[] | 439 chars | yes |
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 configured | Captured | Selected after restore | Warnings |
|---|---|---|---|
| no | { index: 1 } | A-1 | none |
| yes | { id: 'A-2' } | A-2 | none |
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.
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.