Skip to content

Mounting objects

13 min read · Updated 2 Nov 2026

This page belongs to Data App developer guide. The APIs it uses are documented in the Visualizations reference; this page is about using them across a whole dashboard.

Use definitions for initial settings they support, and operations for additional settings or subsequent changes. Avoid configuring the same initial setting through both APIs. Do not hand-write vSpec internals.

Compile definitions against live metadata with compileVisualizationDefinition. Supported settings include sorting, Top N, query limits, legends, axes, data labels, orientation, static filters, totals and table structure; check each type’s definition reference. The compiler rejects unknown sections and properties and returns either a complete vSpec or structured issues; handle issues as in Check operation results.

Operations can run before a chart is mounted. Use applyRuntimeOperations when the runtime owns the instance; it updates the stored vSpec whether the chart is mounted or not. Use applyOperations when your application owns the vSpec; it returns the updated vSpec without updating a runtime instance. See Operations.

Keep the initial definition and any operations used to complete it together so the object can be rebuilt and reviewed from one place.

Resolve data-dependent defaults and apply them to the intended selection groups before mounting objects or starting their first queries. Mounting with an unfiltered group and applying defaults afterward can make each visible object query twice and briefly show the wrong scope. Run independent metadata and default-value lookups in parallel, while keeping dependent initialization in order.

Operations and their payloads are specific to each visualization type. The same idea can exist on one type and not another: setTopNRankMeasure is published by bar and pie, but not by table or line. Look each one up on the type’s page in the operations reference, or read getVisualizationMetadata(type).operations.names in the browser.

Operation names are strings, not typed identifiers. A package upgrade can rename or remove one and your code will still typecheck; the failure appears only when the operation runs. Re-check the operations you use after every upgrade.

Treat a missing field or a refused definition as an error, and a refused operation as one missing setting. Prepare each object in this order: look up its fields, compile its definition, create the instance, apply its operations, check every result, then mount.

  • If a lookup finds no field for the definition, or compilation returns issues, there is nothing to draw. Do not mount the object. Show its error state instead, as in Object states, and let the rest of the page load: one object’s failure never replaces the whole page.
  • If an operation is refused, mount the object with what was accepted and log the refusal. A refused date format should not cost a chart its data.

Put whatever decides the data in the definition, such as fields, aggregation, filters, sorting and Top N. Its refusal stops the object, while a chart missing a refused operation still draws, which is harmless for a colour and misleading for an aggregation.

  • Most refusals do not throw. compileVisualizationDefinition returns success: false with its issues. An operation batch is not transactional (see Operations): each request has its own entry in results, and a refused request reports success: false with an error and changes nothing, while the other requests still run. An operation the type does not publish is different: it rejects the whole call and nothing is applied. Sending requests one at a time, as below, limits that refusal to its own setting too.
  • Log every refusal. An object missing a setting mounts and looks finished, for example with a default-sized number or with labels still visible. The log is how you and Nash find it.
  • A refusal does not appear later by itself. A Builder app builds against the visualization release npm recorded when the app was set up, and reopening and rebuilding use that saved lock; see Package setup in App Builder. A refusal caused by that release or by your code happens every time the code runs, starting in the Builder preview.
  • The semantic layer can change after you publish. Field keys you look up by name come from the metadata the app loads when it opens, not from the preview. If a field is removed or renamed, the lookup finds no key. A definition naming a field that is no longer there fails to compile with unknown_field_key, and the error state is the honest result: the object cannot show what it was built to show. An operation sent without a valid key, or naming such a field, is refused.

Word the error as a failure to load, as Object states does. Do not tell readers to change their filters: the object’s configuration was refused, not its data.

Compilation issues are typed. Operation results are typed as unknown, so check their shape before reading entries:

import { createDefaultVSpec, type DataBinding } from '@astrato-io/visualizations';
import {
compileVisualizationDefinition,
type AtomicInputVisualizationDefinition,
type VisualizationAvailableField,
type VisualizationDefinition,
} from '@astrato-io/visualizations/definitions';
import { applyRuntimeOperations } from '@astrato-io/visualizations/operations';
type ApplyArgs = Parameters<typeof applyRuntimeOperations>[0];
// From the app's setup; see Instance identity and Object states.
declare const runtime: ApplyArgs['runtime'];
declare const availableFields: VisualizationAvailableField[];
declare const dataBinding: DataBinding; // with field metadata, as in Instance identity
declare function markFailed(id: string, error: unknown): void; // shows the object's error state
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const refusedEntries = (results: unknown): unknown[] => {
if (!isRecord(results)) throw new Error(`Unexpected operation results: ${JSON.stringify(results)}`);
return Object.values(results).filter((result) => !isRecord(result) || result.success !== true);
};
// One request at a time, so a refusal, reported or rejected, costs only its own setting.
const applyEach = async (id: string, requests: ApplyArgs['operationRequests']): Promise<unknown[]> => {
const refused: unknown[] = [];
for (const request of requests) {
try {
const applied = await applyRuntimeOperations({
runtime,
instanceId: id,
availableFields,
operationRequests: [request],
});
refused.push(...refusedEntries(applied.results));
} catch (error) {
refused.push({ name: request.name, error: String(error) });
}
}
return refused;
};
const prepareAndMount = async (
id: string,
host: HTMLElement,
// Field lookups run in here, so a missing field stays on this object.
buildDefinition: () => Exclude<VisualizationDefinition, AtomicInputVisualizationDefinition>, // not mountable
operationRequests: ApplyArgs['operationRequests'],
): Promise<void> => {
try {
const definition = buildDefinition();
const compilation = await compileVisualizationDefinition({
definition,
availableFields,
baseVSpec: createDefaultVSpec(definition.type),
});
if (!compilation.success) throw new Error(`Definition refused: ${JSON.stringify(compilation.issues)}`);
runtime.createInstance({
id,
type: definition.type,
groupId: 'dashboard',
vSpec: compilation.vSpec,
dataBinding: { ...dataBinding, queryContext: { objectId: id } },
});
const refused = await applyEach(id, operationRequests);
if (refused.length > 0) console.error(`[${id}] mounted without some settings`, JSON.stringify(refused));
await runtime.mount(host, id); // then handle readiness; see Readiness
} catch (error) {
console.error(`[${id}] not mounted`, error);
markFailed(id, error);
}
};

Log with console.error, not console.warn, and keep the issues and refused entries in it rather than replacing them with a generic message. Each one names the setting or operation and the reason, which is what you need to fix it. Builder shows console errors from the preview, and Nash sees them when it inspects the preview. In a Builder app, do not guard the log with import.meta.env.DEV. Builder builds leave import.meta.env undefined, so the guard itself throws. Builder builds also do not support top-level await, so the example awaits inside a function.

In App Builder, the starter’s Chart and useChart already work this way. useChart also returns the refusal as presentationError.

On a mounted object, a refused request leaves that object’s setting as it was, while the batch’s successful requests stay applied. When a control’s change is refused, set the control back so the page matches the object, as the chart switcher does. When the batch was only partly applied, or the control cannot be set back, show the error state:

// refusedEntries as above. The caller catches this and shows the object's error state.
const checkResults = (results: unknown): void => {
const refused = refusedEntries(results);
if (refused.length > 0) throw new Error(`Operations refused: ${JSON.stringify(refused)}`);
};

Validate payloads before sending them; see Validate your own payloads. Checking results does not replace that.

When investigating a setting that did not apply, compare vSpecs produced by applyOperations with and without the operation. The operation name need not match the property it changes.

Some operations address a sub-item by index, and a new data item has none yet. For example, add a table column’s indicator segment with addColumnIndicatorSegment before setting its colour or icon.

The runtime keeps each instance’s state under its id. Give each visualization a stable, unique id; named operations use it to target the instance. Changing a definition in your application does not itself update a mounted chart. Use supported operations for changes to a mounted instance.

To show a different chart in the same host, unmount the old instance and mount the new one. A component framework can reuse one component across tabs or routes, and a lifecycle that runs only when it first mounts then keeps the old chart under the new title. Run it again when the id or the definition changes, comparing the definition by value, or give each chart its own component key. In App Builder, the starter’s Chart and useChart do this; keep clocks and random values out of their definition and operations, or later changes are not followed.

Set queryContext.objectId on the data binding to the same id, so each object’s queries are addressable:

runtime.createInstance({
id: spec.id,
type: spec.type,
groupId: 'dashboard',
vSpec: compilation.vSpec,
dataBinding: { ...app.dataBinding, fieldMetadata, queryContext: { objectId: spec.id } },
});

The example reuses the field metadata loaded for definition compilation. The runtime can also load metadata through the client’s metadata services; see Filters and selections.

Give every host a non-zero width and height before mounting, using an explicit size or a grid row with a resolved height. height: 100% against an auto-height parent can leave the host with no height. The runtime warns when a mount container has zero width or height.

The pinned package uses ResizeObserver for types such as pie charts, passing later container-size changes to the chart. These objects can recover when the container gains a size; they do not require a remount or synthetic window resize event. Check the affected type if it does not resize as expected.

For long scrolling stories with many objects, lazy-mount charts as their sections approach the viewport. Reserve each host’s size so loading does not shift the reading position. Use application visibility tracking, such as IntersectionObserver, to drive the lifecycle below; lazy mounting is an app design choice, not automatic behavior of the runtime.

When readers compare alternative analyses, a tabbed container can share one chart-sized area. Mount only the active tab’s chart and unmount it when switching tabs. Keep directly compared views visible together when seeing both is necessary to answer the question.

An object hidden with display: none, visibility: hidden or by moving it off screen is still mounted, and still re-queries every time its group’s applied selections change.

Track hidden panels as unmounted and mark them stale when applied selections change, without querying. When a panel is shown, prepare the latest state and mount it once. Prepare configuration separately from mounting so preparation does not trigger queries.

runtime.mount() is asynchronous; unmount() is synchronous. Serialize each panel’s lifecycle through one promise chain, as the React note shows for components; otherwise a fast show, hide, show can land a mount after the unmount meant to undo it.

class Panel {
#lifecycle: Promise<void> = Promise.resolve();
#handle: Awaited<ReturnType<typeof runtime.mount>> | undefined;
show(container: HTMLElement) {
this.#lifecycle = this.#lifecycle
.catch(() => undefined)
.then(async () => {
if (this.#handle) return;
this.#handle = await runtime.mount(container, this.id);
// Do not await handle.ready here; see below.
});
}
hide() {
this.#lifecycle = this.#lifecycle
.catch(() => undefined)
.then(() => {
this.#handle?.unmount();
this.#handle = undefined;
});
}
}

Check it: switching panels should cost one query for the panel you showed, and a hidden panel’s host should have no children.

Choose how much of a scrolling story to retain based on measured query and rendering cost. Unmounting distant sections reduces active work but revisiting them can require another query. Keep the shared app client alive, retain the reader’s applied selections, and prepare each returning section from current state. Do not mount every chart merely to hide it with CSS.

handle.ready resolves once, with rendered, error, unmounted or timeout. The runtime provides a 40-second deadline, configurable through readyTimeoutMs; see Mounting. Use this built-in deadline when waiting for readiness.

Show errors and timeouts on the affected object and allow the remaining dashboard to load.

A timeout is not a failure. It means no render was reported by the deadline. The chart stays mounted and can still render, but nothing reports that later render: handle.ready stays resolved as timeout, and awaiting it again changes nothing. Show the timeout state, never the error state, and leave its Retry to mount the object again. In App Builder a console.error counts as a preview problem, so log a timeout with console.info.

Do not await ready inside a panel’s lifecycle chain. The chain exists to order mount against unmount. Waiting for the first render or timeout there delays later show and hide requests. Handle readiness separately after mounting:

const handle = await runtime.mount(container, id);
void handle.ready.then((result) => {
if (result.status === 'error') markFailed(id, result.status);
if (result.status === 'timeout') markTimedOut(id);
});

Keep lifecycle chains separate for each panel, and read the latest state when processing each update.

Account for background tabs. Browsers can pause animation frames in hidden tabs, so the deadline may expire before a chart renders. Nothing is wrong, so show nothing: keep the loading state and mount the object again when the reader comes back.

declare function remount(id: string): void; // through the panel's lifecycle chain
declare function showTimeoutState(id: string): void;
const markTimedOut = (id: string) => {
if (!document.hidden) return showTimeoutState(id);
document.addEventListener('visibilitychange', () => remount(id), { once: true });
};
  • Column widths. setTableColumnWidthBehavior switches between responsive, which divides the available width between all columns and truncates every cell on a narrow window, and static, used with setTableAllColumnsWidth, which lets the table scroll horizontally inside its frame. Switch based on the host’s width from a ResizeObserver, applying the operation only when the answer changes.
  • Exact widths. In @astrato-io/visualizations 0.6.4 and 0.6.5, every column also starts with responsive width on, which shrinks column widths in browser windows up to 1000 px wide or tall, including many laptop windows. For exact widths, turn it off with setTableAllColumnsResponsiveWidth: false, or for one column with setColumnResponsiveWidth: false.
  • Per-column widths. setColumnWidth takes a string ending in px ("160px"), while setTableAllColumnsWidth takes a bare number. Per-column operations identify the column by its data item’s dataItemInternalKey and dataItemUniqueSignature.
  • Wrap headings instead of truncating them. setTableWordWrapHeader with setTableHeaderResponsiveHeight. A heading like Revenue vs last Year cut to Revenue vs last Y… loses exactly the part that says what it compares against.
  • Totals. In the definition’s totals, or with setTableTotalsEnabled, setTableTotalsApplyTo and setTableTotalsPosition.
  • Representations. setColumnRepresentation can draw a cell as an indicator or a progress bar. For a share, turn the dynamic scale off (setColumnProgressDynamicScaleEnabled) and pin the target to 1; otherwise each bar is scaled against the column’s largest value, so 87% draws full and 79% nearly full.
  • Set widths deliberately. The widest thing in a column decides whether it reads, and it is often not a value: a totals label such as # of records: 5,009 in a column sized for short names.
  • Size fixed rows for cell padding. Table cells have 8px vertical and 12px horizontal padding, and no operation changes it. With setTableFixedRowHeightValue, a row needs the text’s line height plus 16px: 10px text in 16px rows is clipped, and a 22px figure needs about 42px. A one-row table used as a figure also needs its host taller than the row, or it shows scrollbars. Check that no cell has scrollHeight greater than clientHeight.

Table rows use role="row". Data rows carry data-testid="body-row", and totals rows carry data-testid="total-row". Use these markers to distinguish row types. Do not infer a result index by subtracting a fixed value from aria-rowindex: header configuration and totals placement affect it.

Rows are virtualized, so sibling order does not identify a result row. Cells carry role="gridcell", which is how an object menu finds the value under the pointer.

One delegated mouseover/mouseout listener on the host is enough for a hover card or row menu, and the row’s getBoundingClientRect() gives a position that survives scrolling.

Match hover-card data by the row’s dimension values rather than its index, because table and query row orders can differ. Ensure those values uniquely identify the row.

This DOM structure is not a documented API contract. Re-check it after a package upgrade.

  • Use grouped bars for measures with different units. Stacking dollars and transaction counts produces a meaningless total. Set the layout with setBarChartLayout.
  • Allow space for a pie’s legend. Reserve height for a legend below the plot and check that all labels fit at narrow widths.
  • Temporal axes. When a model’s month dimension renders names (Jan, Feb), every ordering on it is alphabetical. Group a real date into a date part instead, with setDimensionTemporalGrouping, so values stay sortable and a current and a comparison series line up on one axis.
  • Date labels. Grouping a date picks its label format. A format string is refused until the format type is custom, and a format type must fit the grouping; see Date labels.
  • Set the line curve. A line chart compiled from a definition has no curve setting. Set one with setLineType (straight, curve or square) rather than relying on the renderer’s choice.
  • A hidden legend takes no position. A definition with legend: { visible: false } and a position fails final validation (invalid_compiled_vspec), which does not name the field. Add position only when the legend is visible.
  • Start a large tree small. Over a large hierarchy, set compact: true and initialExpandedLevels: 1 under presentation.layout in the tree definition, so the first view stays readable.
  • Sankey publishes far fewer operations than the other chart types. Check its operations page before promising a styling choice.