Semaphor
Data Apps

Tables

Build sortable, paginated, server-backed tables for Data Apps.

Tables are common in Data Apps because users often need to inspect records, compare rows, and export or act on detailed data.

The most important table rule is simple: do not fetch a million rows into the browser. Use server-side pagination, sorting, and filtering.

Basic Records Table

const latestOrders = semaphor.records({
  id: 'latest_orders',
  label: 'Latest Orders',
  source: orders,
  fields: [
    orderId,
    orderDate,
    campaignName,
    revenue,
  ],
  orderBy: {
    field: orderDate,
    direction: 'desc',
  },
  limit: 100,
});

Render with columns[].key:

function OrdersTable() {
  const result = useSemaphorQuery(latestOrders);

  if (result.isLoading) return <div>Loading orders...</div>;
  if (result.error) return <div>{result.error.message}</div>;

  return (
    <table>
      <thead>
        <tr>
          {result.columns?.map((column) => (
            <th key={column.key}>{column.label || column.key}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {result.records.map((row, rowIndex) => (
          <tr key={rowIndex}>
            {result.columns?.map((column) => (
              <td key={column.key}>{String(row[column.key] ?? '')}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Server Pagination

Use the query pagination request instead of changing only the rendered slice.

const ordersPage = semaphor.records({
  id: 'orders_page',
  label: 'Orders',
  source: orders,
  fields: [orderId, orderDate, campaignName, revenue],
  orderBy: {
    field: orderDate,
    direction: 'desc',
  },
  pagination: {
    page,
    pageSize: 50,
  },
});

const result = useSemaphorQuery(ordersPage, {
  inputs: [campaignInput, dateRangeInput],
});

The result can include pagination metadata:

result.pagination?.page;
result.pagination?.pageSize;
result.pagination?.hasNextPage;
result.pagination?.hasPrevPage;
result.pagination?.totalCount;

Server Sorting

When a user clicks a column header, update the query's orderBy and let Semaphor fetch a new page.

const ordersPage = semaphor.records({
  id: 'orders_page',
  label: 'Orders',
  source: orders,
  fields: [orderId, orderDate, campaignName, revenue],
  orderBy: {
    field: sortField,
    direction: sortDirection,
  },
  pagination: {
    page,
    pageSize,
  },
});

For large data, sorting in client code after fetching one page is misleading. It sorts only the current page, not the whole dataset.

Total Rows

Tables with numeric columns often need totals. Prefer a separate governed metric query for totals rather than summing only the current page.

const ordersTotal = semaphor.metric({
  id: 'orders_total_revenue',
  label: 'Total Revenue',
  source: orders,
  measures: [revenue],
  primaryMeasure: revenue,
});

Use the same inputs as the table:

const pageResult = useSemaphorQuery(ordersPage, { inputs });
const totalResult = useSemaphorQuery(ordersTotal, { inputs });

This gives users both:

  • page rows for inspection;
  • correct total revenue across the filtered population.

TanStack Table

For rich table UI, TanStack Table is a good fit because it separates table state from data fetching. Use it in manual server-side mode:

const table = useReactTable({
  data: result.records,
  columns,
  manualPagination: true,
  manualSorting: true,
  pageCount: result.pagination?.pageCount,
  state: {
    pagination,
    sorting,
  },
  onPaginationChange: setPagination,
  onSortingChange: setSorting,
});

Pair TanStack Table with the Semaphor query spec state:

For very large tables, add virtualization, but keep data loading server-side.

Table Checklist

  • Always show loading, empty, and error states.
  • Use columns[].key to render cells.
  • Use server pagination for large tables.
  • Use server sorting for sortable columns.
  • Use separate metric queries for totals across all filtered rows.
  • Use filters through inputs, not client-side row filtering.
  • Keep row limits bounded.

On this page