Skip to content

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…

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 { 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>
)
}
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.

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.

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.

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.

const controller = useMemo(
() => createTableController({ columns, data: rows, pageSize: 6 }),
[rows], // rows come from the meter service
)
// driven by an external toolbar
void 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.

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.

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
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).