MeterTable
A table component whose rows come from the meter service: pass a load function that queries the client (filtering values are forwarded, sorting and pagination semantics are decided by load), or feed rows you already fetched to the built-in in-memory controller.
Loading interactive preview…
Loading interactive preview…
View code
Actual React source for this preview, including demo controls, shared styles and isolated mocks. The docs host supplies the client and theme; application installation and React / Vue usage follow below.
import { useEffect, useMemo, useRef, useState } from "react"import { MeterTable } from "@meterry/component-react"import { createTableController, type MeterFilterController,} from "@meterry/component"import { loadPreviewRows, type PreviewEnvironment } from "./preview-data"import { styleProps, columns, sharedColumns } from "./preview-shared"
export function TablePreview({ environment, sharedController,}: { environment: PreviewEnvironment sharedController?: MeterFilterController}) { const selectedColumns = sharedController ? sharedColumns : columns const failNextLoad = useRef(false) const [loadStatus, setLoadStatus] = useState("idle") const controller = useMemo( () => createTableController({ columns: selectedColumns, pageSize: 6, load: async (query) => { if (failNextLoad.current) { failNextLoad.current = false throw new Error( "Simulated reload failure. Previously loaded rows are retained." ) } return loadPreviewRows( environment, query, sharedController?.getValues() ) }, }), [environment, selectedColumns, sharedController] ) useEffect( () => sharedController?.subscribe(() => { void controller.setPage(1) }), [controller, sharedController] ) useEffect(() => { setLoadStatus(controller.getState().status) return controller.subscribe((state) => setLoadStatus(state.status)) }, [controller]) return ( <div data-testid="table-preview"> <MeterTable {...styleProps} title="Usage events" caption="Synthetic preview data" columns={selectedColumns} controller={controller} pageSizeOptions={[6, 12, 24]} /> <div className="docs-preview-actions docs-preview-reload-actions"> <button type="button" disabled={ loadStatus !== "success" || !controller.getState().rows.length } onClick={() => { failNextLoad.current = true void controller.refresh() }} > Simulate reload failure </button> <button type="button" disabled={loadStatus !== "error"} onClick={() => void controller.refresh()} > Retry load </button> </div> </div> )}import { useSyncExternalStore } from "react"import { createEChartsSeriesRegistry, type ChartSeries, type MeterClassNames, type MeterFilterValues, type TableColumn, type UsageTableRow,} from "@meterry/component"import { METRICS, MODELS, type PreviewEnvironment, type PreviewRow, type TrendRow,} from "./preview-data"
export const EMPTY_FILTERS: MeterFilterValues = {}const classNames: MeterClassNames = { root: "docs-preview-root", card: "docs-preview-card", header: "docs-preview-header", caption: "docs-preview-caption", title: "docs-preview-title", body: "docs-preview-body", toolbar: "docs-preview-filter-bar", filterBar: "docs-preview-filter-bar", filter: "docs-preview-filter", table: "docs-preview-table", list: "docs-preview-list", listFooter: "docs-preview-pagination", pagination: "docs-preview-pagination", actions: "docs-preview-actions", button: "docs-preview-button", chart: "docs-preview-chart", value: "docs-preview-value", unit: "docs-preview-unit", empty: "docs-preview-status", loading: "docs-preview-status", error: "docs-preview-status",}export const styleProps = { unstyled: true, classNames }// Adapt only preview label colors to Starlight, without mutating the global registry.export const previewSeriesRegistry = createEChartsSeriesRegistry()for (const type of ["pie", "doughnut"]) { const builtin = previewSeriesRegistry.builder(type) previewSeriesRegistry.register( type, (series, index, model) => ({ ...builtin(series, index, model), label: { color: model.theme.colors.text, textBorderWidth: 0, }, }), { polar: true } )}
export const columns: TableColumn<PreviewRow>[] = [ { key: "bucket", header: "Date", sortable: true }, { key: "model", header: "Model", sortable: true, filter: { type: "select", options: MODELS.map((value) => ({ label: value, value })), }, }, { key: "provider", header: "Provider" }, { key: "metric", header: "Metric", filter: { type: "select", options: METRICS.map((value) => ({ label: value, value })), }, }, { key: "quantity", header: "Quantity", sortable: true, align: "right", filter: { type: "number", placeholder: "Minimum quantity" }, format: (value) => Number(value).toLocaleString("en-US"), }, { key: "amount", header: "Amount (USD)", sortable: true, align: "right", format: (value) => `$${Number(value).toFixed(4)}`, },]// Shared Model/Provider controls and table-local Metric/Quantity controls are independent.export const sharedColumns = columns.map((column) => column.key === "model" ? { ...column, filter: undefined } : column)
export const trendSeries: ChartSeries<TrendRow>[] = [ { key: "input", label: "Input tokens", value: "input_tokens" }, { key: "output", label: "Output tokens", value: "output_tokens" },]
// API identity is raw_event_id; override the snapshot's legacy default event column.export const eventColumns: TableColumn<UsageTableRow>[] = [ { key: "raw_event_id", header: "Event ID" }, { key: "occurred_at", header: "Occurred at", format: (value) => new Date(Number(value) * 1000).toISOString().slice(0, 10), }, { key: "source", header: "Source" }, { key: "model", header: "Model", accessor: (row) => (row.labels as Record<string, string> | undefined)?.model, },]
export function RequestSummary({ environment,}: { environment: PreviewEnvironment}) { useSyncExternalStore( environment.subscribeRequests, () => environment.requests.length, () => 0 ) const details = environment.requests.filter((request) => request.path.endsWith("/details") ).length const trend = environment.requests.filter((request) => request.path.endsWith("/analysis/query") ).length const aggregate = environment.requests.filter((request) => request.path.endsWith("/usage/query") ).length return ( <output className="docs-preview-filter-summary" data-testid="request-count"> Mock API requests: {environment.requests.length} · Events: {details} · Trend: {trend} · Aggregate: {aggregate} </output> )}import { SubjectClient, createSubjectTokenProvider, mergeMeterFilters, type AnalysisQueryRequest, type AnalysisQueryResponse, type FetchLike, type MeterFilterValues, type TableQuery, type TableResult, type UsageQueryRequest, type UsageQueryResponse,} from "@meterry/component"
export const DAY = 86_400export const MODELS = ["gpt-4o-mini", "claude-sonnet", "glm-5.1"] as constexport const PROVIDERS = ["openai", "anthropic", "r9s"] as constexport const METRICS = ["input_tokens", "output_tokens"] as constexport type PreviewScenario = "sample" | "empty" | "error"export type PreviewRow = { bucket: string model: string provider: string metric: string quantity: number amount: number}export type TrendRow = { bucket: string input_tokens: number output_tokens: number}export type PreviewRequest = { path: string; afterId?: string }export type PreviewEnvironment = { requests: PreviewRequest[] subscribeRequests: (listener: () => void) => () => void failNextRequest: () => void client: SubjectClient startTime: number endTime: number}
/** Synthetic browser-only fixtures, not production usage or pricing. */export function createFixture(now = Date.now()) { const today = Math.floor(now / 1000 / DAY) * DAY const startTime = today - 6 * DAY const rows: PreviewRow[] = [] for (let day = 0; day < 7; day++) { for (let model = 0; model < MODELS.length; model++) { for (let metric = 0; metric < METRICS.length; metric++) { const quantity = 1000 + day * 140 + model * 600 + metric * 400 rows.push({ bucket: new Date((startTime + day * DAY) * 1000) .toISOString() .slice(0, 10), model: MODELS[model]!, provider: PROVIDERS[model]!, metric: METRICS[metric]!, quantity, amount: Number((quantity * 0.000002).toFixed(6)), }) } } } return { rows, startTime, endTime: Math.floor(now / 1000) + 1 }}
function selectRows( rows: PreviewRow[], request: UsageQueryRequest | AnalysisQueryRequest) { const metrics = request.metrics?.length ? request.metrics : METRICS return rows.filter((row) => { const timestamp = Date.parse(row.bucket) / 1000 if ( timestamp < (request.start_time ?? -Infinity) || timestamp >= (request.end_time ?? Infinity) ) return false if (!metrics.includes(row.metric as (typeof METRICS)[number])) return false return Object.entries(request.filters ?? {}).every(([key, value]) => { if (value === "") return true if (key === "quantity" || key === "amount") { return Number.isFinite(Number(value)) && row[key] >= Number(value) } return key in row && String(row[key as keyof PreviewRow]) === value }) })}
function analysisResponse( rows: PreviewRow[], request: AnalysisQueryRequest): AnalysisQueryResponse { const groups = new Map< string, { metric: string measure: string model?: string points: Map<string, number> } >() for (const row of selectRows(rows, request)) { for (const measure of request.measures) { const model = request.group_by?.includes("model") ? row.model : undefined const key = [row.metric, measure, model].filter(Boolean).join(":") const group = groups.get(key) ?? { metric: row.metric, measure, model, points: new Map(), } const value = measure === "amount" ? row.amount : measure === "item_count" ? 1 : row.quantity group.points.set(row.bucket, (group.points.get(row.bucket) ?? 0) + value) groups.set(key, group) } } let remaining = request.limit ?? Infinity const series: AnalysisQueryResponse["series"] = [] for (const [key, group] of groups) { const points = [...group.points] .sort(([a], [b]) => a.localeCompare(b)) .slice(0, remaining) .map(([bucket, value]) => ({ bucket, value })) remaining -= points.length if (points.length) series.push({ key, label: group.metric, measure: group.measure, dimensions: { metric: group.metric, ...(group.model ? { model: group.model } : {}), }, points, }) } return { group_by: request.group_by ?? ["bucket"], measures: request.measures, series, meta: { timezone: "UTC", bucket_size: request.bucket_size ?? "1d", start_time: request.start_time, end_time: request.end_time, }, }}
function usageResponse( rows: PreviewRow[], request: UsageQueryRequest): UsageQueryResponse { const dimensions = request.group_by ?? ["bucket", "model", "metric"] const groups = new Map< string, { dimensions: Record<string, string>; measures: Record<string, number> } >() for (const row of selectRows(rows, request)) { const values = Object.fromEntries( dimensions.map((key) => [key, String(row[key as keyof PreviewRow] ?? "")]) ) const key = JSON.stringify(values) const group = groups.get(key) ?? { dimensions: values, measures: {} } for (const measure of request.measures ?? ["quantity"]) { const value = measure === "amount" ? row.amount : measure === "item_count" ? 1 : row.quantity group.measures[measure] = (group.measures[measure] ?? 0) + value } groups.set(key, group) } return { rows: [...groups.values()].slice(0, request.limit ?? groups.size) }}
function delay( milliseconds: number, signal?: AbortSignal | null): Promise<void> { return new Promise((resolve, reject) => { const abort = () => { clearTimeout(timer) signal?.removeEventListener("abort", abort) reject(new DOMException("Preview request aborted", "AbortError")) } const timer = setTimeout(() => { signal?.removeEventListener("abort", abort) resolve() }, milliseconds) if (signal?.aborted) abort() else signal?.addEventListener("abort", abort, { once: true }) })}
/** Every request stays in this fetch adapter; no network or stored credentials. */export function createPreviewEnvironment( options: { scenario?: PreviewScenario now?: number delayMs?: number } = {}): PreviewEnvironment { const fixture = createFixture(options.now) const rows = options.scenario === "empty" ? [] : fixture.rows const requests: PreviewRequest[] = [] const listeners = new Set<() => void>() let failNext = false const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }) const fetch: FetchLike = async (input, init) => { const url = new URL( typeof input === "string" ? input : input instanceof URL ? input.href : input.url, "https://docs-preview.invalid" ) requests.push({ path: url.pathname, afterId: url.searchParams.get("after_id") ?? undefined, }) for (const listener of listeners) listener() await delay(options.delayMs ?? 180, init?.signal) if ( url.origin !== "https://docs-preview.invalid" || ![ "/v1/subject/analysis/query", "/v1/subject/usage/query", "/v1/subject/usage/events/details", ].includes(url.pathname) ) return json({ message: "Unknown preview endpoint" }, 404) if (options.scenario === "error" || failNext) { failNext = false return json({ message: "Simulated preview request failure" }, 503) } if (url.pathname.endsWith("/details")) { if (init?.method !== "GET") return json({ message: "Preview details require GET" }, 405) const request: UsageQueryRequest = { start_time: Number(url.searchParams.get("start_time")) || undefined, end_time: Number(url.searchParams.get("end_time")) || undefined, metrics: url.searchParams .getAll("metrics") .flatMap((value) => value.split(",")), metric: url.searchParams.get("metric") ?? undefined, } if (!request.metrics?.length && request.metric) request.metrics = [request.metric] const events = selectRows(rows, request).map((row) => ({ raw_event_id: `raw_preview_${String(fixture.rows.indexOf(row) + 1).padStart(4, "0")}`, occurred_at: Date.parse(row.bucket) / 1000, source: "documentation", subject_type: "user", subject_id: "documentation-preview", labels: { model: row.model, provider: row.provider }, metrics: { [row.metric]: { value: String(row.quantity), amount: String(row.amount), currency: "USD", }, }, })) const after = url.searchParams.get("after_id") const cursorIndex = after ? events.findIndex((event) => event.raw_event_id === after) : -1 if (after && cursorIndex < 0) return json({ message: "Invalid preview cursor" }, 400) const start = cursorIndex + 1 const limit = Math.min( 1000, Math.max(1, Number(url.searchParams.get("limit")) || 100) ) const page = events.slice(start, start + limit) return json({ usage_events: page, total: events.length, ...(start + page.length < events.length ? { next_after_id: page.at(-1)?.raw_event_id } : {}), }) } if (init?.method !== "POST") return json({ message: "Preview query requires POST" }, 405) let body: UsageQueryRequest | AnalysisQueryRequest try { body = JSON.parse(String(init?.body ?? "{}")) } catch { return json({ message: "Invalid preview query" }, 400) } if (!Array.isArray(body.measures)) return json({ message: "Missing preview measures" }, 400) return json( url.pathname.endsWith("/analysis/query") ? analysisResponse(rows, body as AnalysisQueryRequest) : usageResponse(rows, body) ) } const provider = createSubjectTokenProvider( async () => ({ token_type: "Bearer", access_token: "documentation-preview-only", expires_in: 900, expires_at: Math.floor(Date.now() / 1000) + 900, subject: { type: "user", id: "documentation-preview" }, rules: ["analysis"], }), { tokenStorage: null } ) return { requests, subscribeRequests: (listener) => { listeners.add(listener) return () => { listeners.delete(listener) } }, failNextRequest: () => { failNext = true }, client: new SubjectClient(provider, { baseUrl: "https://docs-preview.invalid", credentials: "omit", fetch, }), startTime: fixture.startTime, endTime: fixture.endTime, }}
export async function fetchPreviewTrend( environment: PreviewEnvironment, filters: MeterFilterValues = {}, signal?: AbortSignal): Promise<TrendRow[]> { const response = await environment.client.analysisQuery( { bucket_size: "1d", start_time: environment.startTime, end_time: environment.endTime, metrics: [...METRICS], group_by: ["bucket"], measures: ["quantity"], filters, }, { signal } ) const byBucket = new Map<string, TrendRow>() for (const series of response.series) { const metric = series.dimensions?.metric if (metric !== "input_tokens" && metric !== "output_tokens") continue for (const point of series.points) { const row = byBucket.get(point.bucket) ?? { bucket: point.bucket, input_tokens: 0, output_tokens: 0, } row[metric] += Number(point.value) byBucket.set(point.bucket, row) } } return [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket))}
export async function loadPreviewRows( environment: PreviewEnvironment, query: TableQuery, shared: MeterFilterValues = {}): Promise<TableResult<PreviewRow>> { const response = await environment.client.usageQuery({ bucket_size: "1d", start_time: environment.startTime, end_time: environment.endTime, metrics: [...METRICS], group_by: ["bucket", "model", "provider", "metric"], measures: ["quantity", "amount"], filters: mergeMeterFilters(query.filters, shared), limit: 500, }) const rows: PreviewRow[] = response.rows.map((row) => ({ bucket: String(row.dimensions.bucket), model: String(row.dimensions.model), provider: String(row.dimensions.provider), metric: String(row.dimensions.metric), quantity: Number(row.measures.quantity), amount: Number(row.measures.amount), })) if (query.sort) { const { key, direction } = query.sort rows.sort((a, b) => { const left = a[key as keyof PreviewRow], right = b[key as keyof PreviewRow] const order = typeof left === "number" && typeof right === "number" ? left - right : String(left).localeCompare(String(right)) return direction === "desc" ? -order : order }) } return { total: rows.length, rows: rows.slice( (query.page - 1) * query.pageSize, query.page * query.pageSize ), }}Installation
Section titled “Installation”npm install @meterry/component @meterry/component-react echarts echarts-for-reactnpm install @meterry/component @meterry/component-vue echarts vue-echartsFor default styles, optionally install @meterry/component-tailwindcss. For authentication, shared clients and theming, see Getting Started.
The remote-load example below forwards column filter values to usage/query; its loader is responsible for sorting and pagination. See Getting Started for a complete service-backed table.
Three data modes
Section titled “Three data modes”These modes do not follow server cursors automatically. A loader that requests limit: 500 and slices that result only pages the returned prefix; total: rows.length is exact only when that result is known to be complete. Do not reuse this bounded-result pattern for full-history queries.
① Remote load (custom data pipelines)
Section titled “① Remote load (custom data pipelines)”The table calls your load for every query change — page, sort, and the filter values of every column that defines a filter:
<MeterTable columns={columns} load={async (query: TableQuery) => { const response = await client.usageQuery({ // time window, metrics, group_by … filters: query.filters, // forwarded to the service }) const rows = response.rows.map(mapToRow) return { rows: paginate(query, rows), total: rows.length } }} error={(loadError) => <RetryButton error={loadError} />}/>Pagination and sorting semantics are decided by load; inside the error render prop you can offer a retry via the controller’s refresh().
② Service data through the local controller
Section titled “② Service data through the local controller”Fetch rows once with the client and hand them to the component as data; filtering, sorting, and pagination then run in memory through the internal createTableController:
const [rows, setRows] = useState()
useEffect(() => { client.usageQuery({ /* … */ }).then((response) => setRows(mapToRow(response)))}, [client])
<MeterTable title="Usage events" columns={columns} data={rows} pageSize={8} />Updating the data reference preserves column filters and sort, but resets the page to 1 (synced internally via setData). This also applies when a shared filter supplies a new filtered array.
③ Controlled controller
Section titled “③ Controlled controller”const controller = useMemo( () => createTableController({ columns, data: rows, pageSize: 6 }), [rows], // rows come from the meter service)
// driven by an external toolbarvoid controller.setSort({ key: 'quantity', direction: 'desc' })void controller.setPage(3)void controller.clearFilters()void controller.refresh()
<MeterTable columns={columns} controller={controller} />The page owns the controller (useMemo), keeps state in sync via subscribe(state => ...), and MeterTable only renders it.
Shared filters
Section titled “Shared filters”The table’s column filters and the shared MeterFilterBar are separate state owners. For local data, filter the rows in the page before passing data. Clearing the shared bar does not clear table-local filters.
For remote loading, keep a stable table controller, merge shared values in its loader, and reload when shared values change. Example React wiring (where columns and loadRows are stable application definitions):
import { useEffect, useMemo } from 'react'import { MeterTable } from '@meterry/component-react'import { createTableController, mergeMeterFilters, type MeterFilterController, type TableColumn, type TableLoad,} from '@meterry/component'
function RemoteTable<T>({ sharedController, columns, loadRows }: { sharedController: MeterFilterController columns: TableColumn<T>[] loadRows: TableLoad<T>}) { const tableController = useMemo(() => createTableController({ columns, load: (query) => loadRows({ ...query, filters: mergeMeterFilters(query.filters, sharedController.getValues()), }), }), [sharedController, columns, loadRows])
useEffect(() => sharedController.subscribe(() => { void tableController.setPage(1) }), [sharedController, tableController])
return <MeterTable columns={columns} controller={tableController} />}loadRows(query: TableQuery) is the application’s remote loader: it forwards query.filters to the service, maps response rows, and implements sorting/pagination before returning { rows, total }. setPage(1) both resets pagination and loads; an extra refresh() is unnecessary. In Vue setup, create the table controller once and register the subscription with onScopeDispose:
onScopeDispose(sharedController.subscribe(() => { void tableController.setPage(1)}))Reading shared values in load alone does not trigger reloads. Do not recreate the controller or loader on each filter change, as that resets table state. Later merge sources win; use separate keys for shared and local concerns where possible. A cleared/missing shared key leaves any table-local value active.
The table controller ignores stale load results, but does not abort the application’s network requests. See remote table integration for scope, matching, and cancellation considerations.
Loading, failures, and retry
Section titled “Loading, failures, and retry”The table keeps previously loaded rows visible while loading a new selection. If the request fails, it shows an error alongside those rows so users know the displayed data has not been refreshed. If no rows have loaded yet, users see a loading message or an error instead.
Add a Retry action with controller.refresh() to load the current filters, sort order, and page again:
<MeterTable columns={columns} controller={controller} error={(cause) => ( <div> {cause.message} <button type="button" className="cursor-pointer" onClick={() => void controller.refresh()}> Retry </button> </div> )}/>In the preview, click Simulate reload failure after sample rows load, then Retry load. The rows remain visible with an error message until a retry succeeds.
| Prop | Type | Default | Description |
|---|---|---|---|
columns |
TableColumn<T>[] |
— (required) | Column definitions, see below |
data |
T[] |
— | Local data (typically rows fetched from the meter service) |
load |
TableLoad<T> |
— | Remote loader (query) => Promise<{ rows, total }> |
controller |
TableController<T> |
— | Controlled controller (when passed, the component does not create its own) |
pageSize |
number |
10 |
Rows per page |
pageSizeOptions |
number[] |
[10, 25, 50] |
Rows-per-page options |
title / caption |
string |
messages / — | Title and description |
empty / loading |
Node |
messages defaults | Empty and loading states |
error |
(error: Error) => Node |
— | Custom error rendering (pair with controller.refresh() to retry) |
slots |
MeterTableSlots<T> |
— | Replace the header / toolbar / cell / pagination rendering |
messages |
Partial<MeterMessages> |
— | Copy overrides |
className / classNames / unstyled |
— | — | Styling |
TableColumn
Section titled “TableColumn”type TableColumn<T> = { key: string header: ReactNode | VNode accessor?: TableAccessor<T> // defaults to row[key] sortable?: boolean align?: 'left' | 'right' | 'center' width?: string | number format?: (value, row, index) => Node filter?: TableFilterDefinition<T>}filter.type: 'text' | 'number' | 'select'; select takes options: { label, value }[]; any type also accepts match?: (value, filterValue) => boolean for custom matching semantics (date ranges, multi-select, >=, etc.). In load mode the filter values are forwarded to the service, which decides the matching semantics; in local data mode match runs in memory.
<MeterTable slots={{ toolbar: ({ filters, controller }) => <MyToolbar ... />, cell: ({ value, row, index, column }) => <Badge>{value}</Badge>, pagination: ({ state, controller }) => <MyPagination ... />, }}/>See API Reference · core for the full set of TableController methods (getState / subscribe / load / refresh / setPage / setPageSize / setSort / setFilter / clearFilters / setData).