Skip to content

MeterList / MeterUsageList

A Table with Load more pagination: one shared column header and aligned rows. The bottom Load more button appends rows without replacing earlier pages. It uses the same TableColumn<T> definitions and table styles as MeterTable, not cards or arbitrary object-key rendering. Headers remain visible during loading, empty and error states.

Loading interactive preview…

View codeHide 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 { useMemo, useState } from "react"
import {
MeterList,
MeterUsageList,
} from "@meterry/component-react"
import { METRICS, type PreviewEnvironment } from "./preview-data"
import { styleProps, eventColumns, RequestSummary } from "./preview-shared"
export function AutomaticListPreview({
environment,
}: {
environment: PreviewEnvironment
}) {
const [source, setSource] = useState<"events" | "usage">("events")
const [custom, setCustom] = useState(false)
const activities = useMemo(
() =>
Array.from({ length: 7 }, (_, index) => ({
title: `Activity ${index + 1}`,
detail: "Local data · appended without replacing earlier items",
})),
[]
)
return (
<div className="docs-preview-stack" data-testid="automatic-list-preview">
<MeterUsageList
{...styleProps}
source={source}
pageSize={3}
batchSize={6}
title="Usage activity"
caption="Query and append · no application loader or cursor"
query={
source === "events"
? {
start_time: environment.startTime,
end_time: environment.endTime,
}
: {
start_time: environment.startTime,
end_time: environment.endTime,
metrics: [...METRICS],
group_by: ["bucket", "model", "metric"],
measures: ["quantity"],
}
}
columns={source === "events" ? eventColumns : undefined}
slots={
custom
? {
cell: ({ column, value }) =>
column.key === "raw_event_id" || column.key === "model" ? (
<strong>{String(value ?? "")}</strong>
) : typeof value === "object" && value !== null ? (
JSON.stringify(value)
) : (
String(value ?? "")
),
}
: undefined
}
/>
<details className="docs-preview-options">
<summary>Example controls &amp; local data</summary>
<div className="docs-preview-options-body">
<div className="docs-preview-actions">
<label className="docs-preview-chart-select">
List source
<select
value={source}
onChange={(event) =>
setSource(event.target.value as "events" | "usage")
}
>
<option value="events">Events · automatic cursor</option>
<option value="usage">Aggregate · bounded result</option>
</select>
</label>
<label className="docs-preview-chart-select">
<input
type="checkbox"
checked={custom}
onChange={(event) => setCustom(event.target.checked)}
/>
Custom cell rendering
</label>
</div>
<RequestSummary environment={environment} />
<div className="docs-preview-actions">
<button type="button" onClick={() => environment.failNextRequest()}>
Fail next API request
</button>
<span className="docs-preview-filter-summary">
Then fetch another batch; Retry retains the displayed items.
</span>
</div>
<MeterList
{...styleProps}
data={activities}
pageSize={3}
title="Local activity"
columns={[
{ key: "title", header: "Activity" },
{ key: "detail", header: "Description" },
]}
/>
</div>
</details>
</div>
)
}
Terminal
npm install @meterry/component @meterry/component-react echarts echarts-for-react

For default styles, optionally install @meterry/component-tailwindcss. For authentication, shared clients and theming, see Getting Started.

Existing table and chart APIs are unchanged.

The automatic demo uses 6-row API batches and appends 3 rows per click. The third displayed page crosses the first batch boundary. Refresh starts over; Expand Example controls & local data to switch sources or try Fail next API request and built-in Retry. The request counter makes cache reuse visible. No production requests or stored tokens are used.

import { MeterClientProvider, MeterUsageList } from '@meterry/component-react'
export function UsagePage() {
return (
<MeterClientProvider tokenEndpoint="/api/meter/subject-token"
options={{ baseUrl: '', credentials: 'include', tokenStorage: null }}>
<MeterUsageList source="events" rangeDays={7} pageSize={20} />
</MeterClientProvider>
)
}

The backend-owned token route returns a short-lived Subject Token grant. Tenant API keys never belong in browser code. baseUrl: '' assumes same-origin /v1/...; configure your actual API origin or proxy prefix otherwise. Existing explicit client and provider getJwt authentication also work.

MeterUsageList has source-specific default columns. Pass columns to choose exactly which columns appear, their order, header labels, widths and alignment:

import type { TableColumn, UsageListRow } from '@meterry/component'
const columns: TableColumn<UsageListRow>[] = [
{ key: 'raw_event_id', header: 'Event ID', width: '16rem' },
{ key: 'source', header: 'Source' },
{ key: 'model', header: 'Model', accessor: (row) =>
(row.labels as Record<string, unknown> | undefined)?.model },
]
// Inside the provider above:
<MeterUsageList source="events" columns={columns} pageSize={20} batchSize={500}
rowKey="raw_event_id" />

No load, mapper or cursor loop is needed for Meter queries. Default event columns use raw_event_id, occurrence time, source, model and metrics. rowKey is an optional field name or function; the default index preserves append order. Only defined columns render: extra row fields are never enumerated.

Provide a client in an ancestor’s setup, not the list’s own setup:

App.vue
<script setup lang="ts">
import { provideMeterClient } from '@meterry/component-vue'
import UsagePage from './UsagePage.vue'
provideMeterClient({
tokenEndpoint: '/api/meter/subject-token',
options: { baseUrl: '', tokenStorage: null },
})
</script>
<template><UsagePage /></template>
UsagePage.vue
<script setup lang="ts">
import { MeterUsageList } from '@meterry/component-vue'
</script>
<template>
<MeterUsageList source="events" :range-days="7" :page-size="20" />
<!-- Optional custom cells; the table header and row structure stay intact: -->
<MeterUsageList source="events" :page-size="10" row-key="raw_event_id">
<template #cell="{ value, column }">
<strong v-if="column.key === 'raw_event_id'">{{ value }}</strong>
<span v-else>{{ typeof value === 'object' ? JSON.stringify(value) : value }}</span>
</template>
</MeterUsageList>
</template>

Pass :columns to customize headers. Vue also supports a function-valued :slots prop with cell({ value, row, index, column }). Component refs expose loadMore() and refresh().

  • MeterUsageList defaults to source="events", rangeDays=7, pageSize=20, batchSize=500, refreshable=true. One click reveals at most pageSize more rows. Cached rows are revealed without an API request; later clicks fetch next_after_id automatically as after_id. With the default sizes, displaying row 501 fetches the second batch if a cursor exists.
  • A displayed page can span several short API batches. An empty/short batch with a next cursor does not imply completion. Repeated cursors fail rather than loop.
  • The time window is fixed for a query session; Refresh recomputes relative dates. This is not transactional snapshot isolation against concurrent ingestion or revocation. If paging detects an inconsistent dataset, refresh rather than silently repeating a page.
  • Loading-more disables the button and preserves all displayed rows. Rapid clicks share one pending operation. Errors retain the prefix and show Retry; retry requests the failed page/cursor, not the first page.
  • Exact total remains unknown until cursor exhaustion; the backend’s detail total is not used by the current wrapper. At the end the button is removed and No more data. is shown. Empty results display the empty state.
  • source="usage" queries the aggregate endpoint once, then reveals that bounded result locally. There is no aggregate cursor. At batchSize, show an incomplete-result warning and do not label it as all data. query follows the automatic table contract: events accept time/metric selectors only; aggregate requests support dimensions and measures.
  • Refresh, changed source/query/auth client and changed page/batch sizes clear the previous prefix. Equivalent inline query objects or changes to cell renderers/columns/styles do not refetch. Unmount cancels owned work and ignores stale results, even if a custom fetch/loader disregards cancellation.

An append-only list retains every displayed row, so memory and DOM grow with Load more. The transport keeps only a bounded rolling batch cache, not a second copy of all historical pages. This is not a virtualized infinite list or an all-data export. Narrow long histories or use the paginated table for bounded rendering. No client-side sort is advertised as a global server-side sort.

MeterList: local data or a custom page loader

Section titled “MeterList: local data or a custom page loader”

MeterList<T> supports data, load, or a caller-owned controller (highest precedence). Local data takes precedence over a loader. A generic loader returns one complete requested page and the exact matching total; it owns backend pagination if its backend is not the built-in Meter source. Unlike the automatic wrapper, generic load does not discover arbitrary cursor conventions.

import { MeterList } from '@meterry/component-react'
import type { ListLoad } from '@meterry/component'
type Activity = { id: string; title: string }
const loadActivities: ListLoad<Activity> = async ({ page, pageSize, signal }) => {
const response = await fetch(`/api/activities?page=${page}&limit=${pageSize}`, { signal })
if (!response.ok) throw new Error('Unable to load activities')
return response.json() // { rows: Activity[], total: number }
}
const columns = [
{ key: 'title', header: 'Activity' },
{ key: 'id', header: 'ID', width: '10rem' },
]
export function ActivityList() {
return <MeterList load={loadActivities} columns={columns} pageSize={10} rowKey="id" />
}
// For local data: <MeterList data={activities} columns={columns} pageSize={10} />

For Vue, rows have the shape Record<string, unknown>; use :data, :load, :columns, and #cell. Updated local data replaces the prefix and starts at the first page. Keep load stable (outside render or memoized).

Prop MeterList MeterUsageList
Data source data, load, or controller client, source, query, rangeDays, batchSize
columns Required TableColumn<T>[] Optional; source-specific defaults
pageSize 20 20
rowKey Optional field name or (row, index) => string / number Same
refreshable false true
title, caption, empty, loading, error Rendering customization Same
slots header, cell, footer Same
messages, className, classNames, unstyled Shared styling and copy Same

Columns support key, header, accessor, format, width, and align just like Table. This append-only component does not provide sort/filter controls; sortable and filter do not enable them. Sorting only a loaded prefix must not be confused with global server sorting. Change the query to reset the result.

slots.cell uses MeterTableCellSlotContext<T> (value, row, index, column) and replaces one cell, not the whole row. Without a cell slot, column.format or displayTableValue formats the value. The header slot customizes the component title region, not the column header. The table’s thead always comes from columns.

MeterListFooterSlotContext<T> contains state and controller. A custom footer replaces default actions; call controller.loadMore(), load() (retry), and refresh() and preserve disabled/unknown-total semantics.

Styling reuses table for the horizontally scrolling table wrapper, with list as an additional class hook and listFooter for actions. There are no card-field or whole-item renderer APIs. Messages: listTitle, listLoading, listEmpty, loadMore, loadingMore, noMoreItems, listSummary, usageListTruncated; existing retry / refresh are reused.

import { createListController, createUsageListController } from '@meterry/component'
const list = createUsageListController({ client, source: 'events', pageSize: 20 })
// Or: createListController({ data, load, pageSize })
const unsubscribe = list.subscribe((state) => console.log(state))
await list.load()
await list.loadMore()
await list.refresh()
unsubscribe()
list.dispose()

ListState<T> includes status, all displayed rows, last successful page, pageSize, total: number | null, hasMore, truncated, and error. Controller methods are getState, subscribe, load, loadMore, refresh, setData, dispose. load is idempotent after success and retries the failed operation. setData is for generic local lists; automatic usage lists own their query. Caller-provided controllers are not disposed by the rendering component. Clear old auth-scoped controllers/components when switching users or tenants.