component — display

Server Table

A controlled, server-driven data table: every sort, filter, search and page change is reported as one DataTableQuery through a single onQueryChange callback, so exactly one round-trip runs per change. Offset, cursor and load-more pagination with an honest pager when the total is too expensive to count; filter popovers that load a column's distinct values from the source (searchable, paged on scroll); select-all-matching with an exclusion set; a deterministic sort tiebreaker so pagination cannot repeat or drop rows; and cell editing where a commit is a mutation — validated in the browser first, applied optimistically, rolled back if the server refuses. It is assembled from Table Shell and Table Controls — the same two halves Data Table is built from — and depends on neither Data Table nor the client row model it would never run.

  • table
  • data
  • grid
  • server
  • query
  • filter
  • sort
  • pagination
  • search
  • editing
  • interactive

05 examples

Server-driven orders

300 orders behind a stand-in database. Sort (shift-click for a second column), filter from the header popovers — Status, Country and Customer load their distinct values with counts, searchable and paged on scroll, and with enableFilterOperators the popover picks the question too, so "is not" reaches the server as part of the query — search, and page. Each of those is one onQueryChange and one query.

Sorting, filtering, searching and paging are all one onQueryChange callback — exactly one round-trip per change.
Reference
Status
Country
Customer
Amount
Date

No rows

Selected: 0. Select every row on the page and the bar offers "select all 0 matching", which is a different claim from "these ten".

When the total is too expensive

The same table with hasMore instead of total. The pager says "of many" and offers no last page, which is the honest shape when COUNT(*) over the filtered query is the slowest thing on the page.

Reference
Status
Country
Customer
Amount

No rows

With no total the pager degrades honestly: "showing 11–20 of many", no page count, and no last-page button — rather than a page number computed from a count nobody asked the database for.

Infinite scroll

paginationMode="load-more" replaces the pager with react-aria's load-more sentinel. Scroll to the bottom of the 360px viewport to fetch the next page; sorting or filtering starts again from page 0.

Reference
Status
Country
Customer
Amount
Infinite scroll is react-aria's TableLoadMoreItem: a sentinel row at the end of the collection that fires when it comes into view. Sorting or filtering resets to page 0, because the rows above are the answer to a query that no longer applies.

Optimistic update and rollback

Mark an order paid and the row updates immediately. One in three fails on the server and rolls back with an explanation — the case a spinner-free optimistic update has to get right.

Reference
Status
Country
Customer
Amount
Actions

No rows

The row changes before the server answers and changes back if it refuses. The override lives beside the query result rather than in it, so the next query — a sort, a filter, a page — replaces the rows without having to reconcile them.

Editing a cell, against a server

A cell commit is a mutation: the value changes before the server answers, the cell shows that it is in flight, and one in three is refused and rolled back with an explanation. The schema is checked in the browser first, so a bad amount never becomes a request at all.

Reference
Status
Country
Customer
Amount
Date

No rows

Customer, Status and Amount edit in place; the other three have no editor and Tab skips over them. Sort by Amount and then edit one: the row moves under the cursor as the new value re-sorts it, and focus follows the cell rather than falling to the body.

source

server-table.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { useTable } from "@tanstack/react-table"
import type { RowData } from "@tanstack/react-table"
import { ArrowDownToLine, RotateCcw } from "lucide-react"
import { type ReactNode, useMemo, useRef, useState } from "react"
import type * as v from "valibot"
import { useAsyncList } from "react-stately"
import { Button } from "@/components/button"
import {
  CHROME_ICON_SIZE,
  CHROME_SIZE,
  TableBulkBar,
  TableColumnChooser,
  TableDensityToggle,
  TableFilterChips,
  TableFilterPanel,
  TablePager,
  TableSearch,
  TableToolbar,
  describeFilter,
  useTableCellEditing,
} from "@/components/table-controls"
import { TableShell } from "@/components/table-shell"
import {
  type DataTableCellAddress,
  type DataTableCellEdit,
  type DataTableColumn,
  type DataTableDensity,
  type DataTableFilterOption,
  type DataTableQuery,
  type DataTableSelection,
  type FilterCondition,
  type FilterOperator,
  dataTableFeatures,
  defaultOperator,
  emptyQuery,
  emptySelection,
  isFilterSet,
  nextSorting,
  packFilter,
  qualifiedLabel,
  sortingToSorts,
  sortsToSorting,
  toColumnDefs,
  withTiebreak,
} from "@/lib/data-table"

/**
 * Server Table — quebi design system
 *
 * The server-driven mode. Every sort, filter, search and page change is a
 * query: the component reports the whole new `DataTableQuery` through one
 * `onQueryChange` callback and draws the rows it is handed back. It never
 * sorts, filters or pages the `rows` prop — those rows are the answer to the
 * last query, and reordering them reorders a slice.
 *
 * It is `DataTable` with every `manual*` flag set: same column vocabulary
 * (`DataTableColumn` from `@/lib/data-table`), same shell, same controls, same
 * selection model. What differs is where the work happens, and the four places
 * that difference is real — select-all, the total count, faceted values and
 * export — are props rather than assumptions.
 *
 * It does not depend on `DataTable`, and that is the point of the file
 * existing. What the two modes share is the chrome and the render half, which
 * are `@/components/table-controls` and `@/components/table-shell`; both modes
 * are built on those two, and neither mode reaches for the other. Adding a
 * server-driven table to a project therefore brings a toolbar and a shell, not
 * a client row model it will never run.
 *
 * The name says where the rows come from, which is the only question that
 * chooses between the two modes. "Async" said how they arrive, which was never
 * the distinction: `DataTable` has `isLoading`, `isRefreshing`, `error` and
 * `onRetry`, and works perfectly well over a fetch — it just sorts what it was
 * given rather than asking again.
 */

export interface ServerTableFilterPage {
  /**
   * One page of the column's domain. Count each value over the rows the
   * *other* filters leave — that is what makes the number answer "and how many
   * would that leave" — and include the zeroes: the panel lists a zero as a
   * disabled choice rather than dropping it, so a value another filter has
   * emptied can still be seen and explained. A value with no count at all is
   * listed and stays selectable.
   */
  items: DataTableFilterOption[]
  /** Return a cursor to enable "load more" on scroll; omit when exhausted. */
  cursor?: string
}

export interface ServerTableLoadFilterParams {
  /** The column whose distinct values are being requested. */
  column: string
  search: string
  cursor?: string
  signal: AbortSignal
}

export type ServerTableLoadFilterValues = (
  params: ServerTableLoadFilterParams,
) => Promise<ServerTableFilterPage>

export interface ServerTableProps<T extends RowData> {
  "aria-label": string
  columns: DataTableColumn<T>[]
  /** The rows the last query returned. Not a dataset — a page of an answer. */
  rows: T[]
  getRowId: (row: T) => string

  /** Controlled query. Everything the server needs, in one object. */
  query?: DataTableQuery
  onQueryChange?: (query: DataTableQuery) => void
  defaultQuery?: Partial<DataTableQuery>

  /**
   * Rows matching the query. Leave it out when `COUNT(*)` over the filtered
   * query is too expensive, and set `hasMore` instead.
   */
  total?: number
  hasMore?: boolean
  nextCursor?: string | null
  paginationMode?: "offset" | "cursor" | "load-more" | "none"
  pageSizes?: number[]

  /**
   * A column that is unique per row — usually the primary key. It is appended
   * to every sort so the ordering is total, which is what stops page 2 from
   * repeating a row page 1 already showed.
   */
  tiebreakColumn?: string

  /** Loads a page of distinct values for a column's filter popover. */
  loadFilterValues?: ServerTableLoadFilterValues
  /**
   * Whether a column's filter popover draws an Operator select — `is not`,
   * `does not contain`, `not between` — rather than only honouring one that
   * arrived from a URL, a preset or a `FilterBuilder` (task #201).
   *
   * **Off by default, unlike `DataTable`.** Both tables report the operator the
   * same way, but only one of them answers it: `DataTable` narrows its own rows
   * with `matchesFilter`, so every operator the model can express is one it can
   * already apply. Here the answer comes from whatever `onQueryChange` reaches,
   * and a backend that only implements `contains` would return `contains` rows
   * under a header that says `does not contain` — wrong results, silently, with
   * nothing in the client able to notice. So the control appears once the host
   * says its query can answer it.
   *
   * Then it is drawn per column, and only where there is a choice: `boolean`
   * offers one operator, and its `Yes / No / Any` already says what `is not`
   * would.
   */
  enableFilterOperators?: boolean
  /** Named filter sets, offered beside the chips. */
  filterPresets?: { id: string; label: string }[]
  onApplyPreset?: (id: string) => void
  onSavePreset?: () => void

  /* selection */
  selectionMode?: "none" | "single" | "multiple"
  selection?: DataTableSelection
  onSelectionChange?: (selection: DataTableSelection) => void
  bulkActions?: (selection: DataTableSelection) => ReactNode

  /* rows */
  renderDetail?: (row: T) => ReactNode

  /* editing a cell */
  /**
   * The valibot schema a cell edit is validated against — the whole row.
   * Without it nothing is editable, whatever the columns say.
   */
  cellEditSchema?: v.GenericSchema
  /**
   * Reported once the schema accepted the row. Server-side this is a mutation:
   * update optimistically, roll back if the server refuses, and name the cell
   * in `savingCell` while it is in flight — the rows prop is the answer to the
   * last query, so the edit lives beside it until the next one replaces both.
   */
  onCellEdit?: (edit: DataTableCellEdit<T>) => void
  editingCell?: DataTableCellAddress | null
  onEditingCellChange?: (cell: DataTableCellAddress | null) => void
  getEditValues?: (row: T) => Record<string, unknown>
  /**
   * The cell whose commit is in flight, if one is. An address rather than a
   * boolean, because commits no longer wait for the cell to be left: a control
   * commits when its value settles, so by the time the save is running the open
   * cell is routinely a different one, and one flag for the whole table draws
   * the spinner wherever the user happens to be rather than where the edit was.
   */
  savingCell?: DataTableCellAddress | null
  rowActions?: (row: T) => ReactNode
  getRowHref?: (row: T) => string | undefined
  onRowAction?: (row: T) => void
  isRowDisabled?: (row: T) => boolean
  rowClassName?: (row: T) => string | undefined

  /* chrome */
  caption?: ReactNode
  toolbarActions?: ReactNode
  enableGlobalSearch?: boolean
  enableColumnChooser?: boolean
  enableDensityToggle?: boolean
  onRefresh?: () => void
  /**
   * A server-side export. The client only holds a page, so there is nothing
   * here to write to a file — this hands the current query to an endpoint that
   * can stream the whole result.
   */
  onExport?: (query: DataTableQuery) => void

  /* presentation */
  density?: DataTableDensity
  onDensityChange?: (density: DataTableDensity) => void
  striped?: boolean
  grid?: boolean
  allowResize?: boolean
  stickyHeader?: boolean
  height?: number
  showFooter?: boolean

  /* states */
  isLoading?: boolean
  isRefreshing?: boolean
  error?: ReactNode
  onRetry?: () => void
  emptyMessage?: string
  noResultsMessage?: string
  className?: string
}

const EMPTY_ROWS: never[] = []

/** The distinct values for one column, loaded from the source as the user searches. */
function useFilterOptions(
  column: string,
  loadFilterValues: ServerTableLoadFilterValues | undefined,
) {
  const list = useAsyncList<DataTableFilterOption & { id: string }>({
    async load({ signal, cursor, filterText }) {
      if (!loadFilterValues) return { items: [] }
      const page = await loadFilterValues({
        column,
        search: filterText ?? "",
        cursor,
        signal,
      })
      return {
        items: page.items.map((option) => ({ ...option, id: option.value })),
        cursor: page.cursor,
      }
    },
  })
  const debounce = useRef<ReturnType<typeof setTimeout> | null>(null)
  return {
    options: list.items as DataTableFilterOption[],
    isLoading: list.loadingState === "loading" || list.loadingState === "filtering",
    onSearch: (text: string) => {
      if (debounce.current) clearTimeout(debounce.current)
      debounce.current = setTimeout(() => list.setFilterText(text), 250)
    },
    onLoadMore: () => {
      if (list.loadingState === "idle" && list.items.length > 0) list.loadMore()
    },
  }
}

/**
 * The filter popover body, mounted by the popover so it reloads each time the
 * filter opens. Pending edits are a transaction: Apply commits, dismissing
 * discards — which is what keeps one round-trip per change.
 */
function ServerFilterPanel({
  columnId,
  label,
  variant,
  value,
  operator,
  editOperator,
  loadFilterValues,
  onApply,
  onClear,
  onClose,
}: {
  columnId: string
  label: string
  variant: NonNullable<DataTableColumn<never>["filterVariant"]>
  value: unknown
  operator?: FilterOperator
  editOperator?: boolean
  loadFilterValues?: ServerTableLoadFilterValues
  onApply: (value: unknown, operator: FilterOperator) => void
  onClear: () => void
  onClose?: () => void
}) {
  const facets = useFilterOptions(columnId, loadFilterValues)
  const selected = Array.isArray(value) ? (value as string[]) : []
  // Selected values that fell outside the loaded page stay listed and stay
  // checkable, whatever the search or the scroll position happens to be.
  const options = useMemo(() => {
    if (variant !== "enum") return facets.options
    const loaded = new Set(facets.options.map((option) => option.value))
    return [...selected.filter((v) => !loaded.has(v)).map((v) => ({ value: v })), ...facets.options]
  }, [facets.options, selected, variant])

  return (
    <TableFilterPanel
      columnId={columnId}
      label={label}
      variant={variant}
      value={value}
      operator={operator}
      editOperator={editOperator}
      options={options}
      isLoadingOptions={facets.isLoading}
      onSearchOptions={variant === "enum" && loadFilterValues ? facets.onSearch : undefined}
      onLoadMoreOptions={variant === "enum" ? facets.onLoadMore : undefined}
      onApply={onApply}
      onClear={onClear}
      onClose={onClose}
    />
  )
}

export function ServerTable<T extends RowData>({
  "aria-label": ariaLabel,
  columns,
  rows,
  getRowId,
  query: queryProp,
  onQueryChange,
  defaultQuery,
  total,
  hasMore,
  nextCursor,
  paginationMode = "offset",
  pageSizes,
  tiebreakColumn,
  loadFilterValues,
  enableFilterOperators = false,
  filterPresets,
  onApplyPreset,
  onSavePreset,
  selectionMode = "none",
  selection: selectionProp,
  onSelectionChange,
  bulkActions,
  renderDetail,
  cellEditSchema,
  onCellEdit,
  editingCell,
  onEditingCellChange,
  getEditValues,
  savingCell,
  rowActions,
  getRowHref,
  onRowAction,
  isRowDisabled,
  rowClassName,
  caption,
  toolbarActions,
  enableGlobalSearch = true,
  enableColumnChooser = true,
  enableDensityToggle = true,
  onRefresh,
  onExport,
  density: densityProp,
  onDensityChange,
  striped,
  grid,
  allowResize,
  stickyHeader,
  height,
  showFooter,
  isLoading,
  isRefreshing,
  error,
  onRetry,
  emptyMessage,
  noResultsMessage,
  className,
}: ServerTableProps<T>) {
  const [internalQuery, setInternalQuery] = useState<DataTableQuery>({
    ...emptyQuery,
    ...defaultQuery,
  })
  const query = queryProp ?? internalQuery
  const pushQuery = (next: DataTableQuery) => {
    // Every change goes out as one query, with the tiebreaker already on it —
    // so "one round-trip per interaction" is enforceable rather than hoped for.
    const withStableOrder = { ...next, sort: withTiebreak(next.sort, tiebreakColumn) }
    setInternalQuery(withStableOrder)
    onQueryChange?.(withStableOrder)
  }

  const [densityState, setDensityState] = useState<DataTableDensity>(densityProp ?? "normal")
  const density = densityProp ?? densityState
  const [selectionState, setSelectionState] = useState<DataTableSelection>(emptySelection)
  const selection = selectionProp ?? selectionState
  const setSelection = (next: DataTableSelection) => {
    setSelectionState(next)
    onSelectionChange?.(next)
  }

  const columnDefs = useMemo(() => toColumnDefs(columns), [columns])
  const sorting = useMemo(() => sortsToSorting(query.sort), [query.sort])

  /**
   * The same row model as DataTable, with every transform turned off.
   *
   * `manualSorting`, `manualFiltering` and `manualPagination` are what make
   * this the server's table: TanStack still owns the column definitions, the
   * header groups, visibility, ordering, pinning and sizing, but it is told
   * that the rows it was given are already sorted, filtered and paged.
   */
  const table = useTable<typeof dataTableFeatures, T>({
    features: dataTableFeatures,
    columns: columnDefs,
    data: rows ?? EMPTY_ROWS,
    getRowId: (row, index) => String(getRowId(row) ?? index),
    manualSorting: true,
    manualFiltering: true,
    manualPagination: true,
    manualGrouping: true,
    manualExpanding: true,
    // As in DataTable: a detail panel is expansion with no sub-rows behind it,
    // and `toggleExpanded` refuses unless the row says it can expand.
    ...(renderDetail ? { getRowCanExpand: () => true } : {}),
    rowCount: total,
    state: {
      sorting,
      globalFilter: query.search,
      // Nothing here filters — `manualFiltering` is on — but the shape still has
      // to be the one every other reader of a column filter expects, operator
      // included, or a custom `filterFn` would see a different filter from the
      // one the query reports.
      columnFilters: query.filters.map((condition) => ({
        id: condition.fieldId,
        value: packFilter(condition.variant, condition.value, condition.operator),
      })),
      pagination: { pageIndex: query.page, pageSize: query.pageSize },
    },
  })

  const rowModel = table.getRowModel().rows
  const activeFilters = query.filters.filter((condition) => isFilterSet(condition.value))

  const conditionFor = (columnId: string) =>
    query.filters.find((condition) => condition.fieldId === columnId)

  /*
   * A column header offers one condition per column, so setting one replaces
   * whatever that column had. The operator comes back from the panel, which
   * reports the one it was working under whether or not it could edit it — so
   * with `enableFilterOperators` off this is still the operator the condition
   * arrived with, and a query from a URL, a saved view or a condition builder
   * saying `is not` is not quietly reset to `is` by re-applying its value.
   */
  const setFilter = (columnId: string, value: unknown, operator?: FilterOperator) => {
    const variant = table.getColumn(columnId)?.columnDef.meta?.filterVariant
    const previous = conditionFor(columnId)
    const next: FilterCondition[] = query.filters.filter(
      (condition) => condition.fieldId !== columnId,
    )
    if (isFilterSet(value)) {
      next.push({
        id: previous?.id ?? columnId,
        fieldId: columnId,
        operator: operator ?? previous?.operator ?? defaultOperator(variant),
        variant,
        value,
      })
    }
    // A narrower filter can leave you past the end of the result, so a filter
    // change always returns to the first page rather than to an empty one.
    pushQuery({ ...query, filters: next, page: 0, cursor: null })
  }

  const columnLabel = (columnId: string) =>
    table.getColumn(columnId)?.columnDef.meta?.label ?? columnId

  const cellEditing = useTableCellEditing<T>({
    columns,
    schema: cellEditSchema,
    getEditValues,
    onCellEdit,
    editingCell,
    onEditingCellChange,
    savingCell,
  })

  return (
    <div className={"flex w-full flex-col gap-2"}>
      <TableToolbar
        caption={caption}
        actions={
          <>
            {toolbarActions}
            {enableDensityToggle && (
              <TableDensityToggle
                value={density}
                onChange={(next) => {
                  setDensityState(next)
                  onDensityChange?.(next)
                }}
              />
            )}
            {enableColumnChooser && (
              <TableColumnChooser
                columns={table.getAllLeafColumns().map((column) => ({
                  id: column.id,
                  label: qualifiedLabel(column.columnDef.meta, column.id),
                  isVisible: column.getIsVisible(),
                  canHide: column.getCanHide(),
                }))}
                onChange={(id, isVisible) => table.getColumn(id)?.toggleVisibility(isVisible)}
                onReset={() => {
                  table.resetColumnVisibility()
                  table.resetColumnOrder()
                  table.resetColumnPinning()
                }}
              />
            )}
            {onExport && (
              // The chrome height both modes share, taken from the constant
              // rather than restated: every control table-controls exports is
              // already sized to it, and a second copy of the string is how a
              // toolbar ends up two heights tall.
              <Button intent="outline" size={CHROME_SIZE} onPress={() => onExport(query)}>
                <ArrowDownToLine data-slot="icon" aria-hidden="true" />
                Export
              </Button>
            )}
            {onRefresh && (
              <Button
                intent="ghost"
                size={CHROME_ICON_SIZE}
                aria-label="Refresh"
                onPress={onRefresh}
              >
                <RotateCcw data-slot="icon" aria-hidden="true" />
              </Button>
            )}
          </>
        }
      >
        {enableGlobalSearch && (
          <TableSearch
            value={query.search}
            onChange={(search) => pushQuery({ ...query, search, page: 0, cursor: null })}
            placeholder="Search…"
          />
        )}
      </TableToolbar>

      <TableFilterChips
        filters={activeFilters.map((condition) => ({
          column: condition.fieldId,
          label: columnLabel(condition.fieldId),
          // The same words the client mode puts in a chip. Reading the value
          // by its shape instead of by its variant is how a number range used
          // to arrive here as "10, 50".
          text: describeFilter(
            condition.variant ?? table.getColumn(condition.fieldId)?.columnDef.meta?.filterVariant,
            condition.value,
            condition.operator,
          ),
        }))}
        onClear={(columnId) => setFilter(columnId, undefined)}
        onClearAll={() => pushQuery({ ...query, filters: [], page: 0, cursor: null })}
        presets={filterPresets}
        onApplyPreset={onApplyPreset}
        onSavePreset={onSavePreset}
      />

      {selectionMode === "multiple" && (
        <TableBulkBar
          selection={selection}
          total={total}
          pageCount={rowModel.length}
          onSelectAllMatching={() => setSelection({ mode: "all-matching", excluded: [] })}
          onClear={() => setSelection(emptySelection)}
        >
          {bulkActions?.(selection)}
        </TableBulkBar>
      )}

      <TableShell<T>
        aria-label={ariaLabel}
        table={table}
        rows={rowModel}
        getRowKey={getRowId}
        sorting={sorting}
        onSortIntent={(columnId, additive) =>
          pushQuery({
            ...query,
            sort: sortingToSorts(nextSorting(sorting, columnId, { additive })),
            page: 0,
            cursor: null,
          })
        }
        onSortColumn={(columnId, direction) =>
          pushQuery({
            ...query,
            sort: direction == null ? [] : [{ column: columnId, direction }],
            page: 0,
            cursor: null,
          })
        }
        density={density}
        striped={striped}
        grid={grid}
        allowResize={allowResize}
        stickyHeader={stickyHeader}
        height={height}
        selectionMode={selectionMode}
        selection={selection}
        onSelectionChange={setSelection}
        allowSelectAllMatching
        isRowDisabled={isRowDisabled}
        getRowHref={getRowHref}
        onRowAction={onRowAction}
        rowActions={rowActions}
        renderDetail={renderDetail}
        editingCell={cellEditing.editingCell}
        onEditingCellChange={cellEditing.setEditingCell}
        editableColumns={cellEditing.editableColumns}
        renderCellEditor={cellEditing.renderCellEditor}
        rowClassName={rowClassName}
        showFooter={showFooter}
        hasQuery={activeFilters.length > 0 || query.search !== ""}
        isLoading={isLoading}
        isRefreshing={isRefreshing}
        error={error}
        onRetry={onRetry}
        emptyMessage={emptyMessage}
        noResultsMessage={noResultsMessage}
        activeFilters={activeFilters.map((condition) => condition.fieldId)}
        onLoadMore={
          paginationMode === "load-more" && hasMore
            ? () => pushQuery({ ...query, page: query.page + 1, cursor: nextCursor })
            : undefined
        }
        isLoadingMore={paginationMode === "load-more" && isRefreshing}
        renderFilter={(columnId, close) => {
          const meta = table.getColumn(columnId)?.columnDef.meta
          if (!meta?.filterVariant) return null
          return (
            <ServerFilterPanel
              columnId={columnId}
              label={meta.label}
              variant={meta.filterVariant}
              value={conditionFor(columnId)?.value}
              operator={conditionFor(columnId)?.operator}
              editOperator={enableFilterOperators}
              loadFilterValues={loadFilterValues}
              onApply={(value, operator) => setFilter(columnId, value, operator)}
              onClear={() => setFilter(columnId, undefined)}
              onClose={close}
            />
          )
        }}
        className={className}
      />

      {(paginationMode === "offset" || paginationMode === "cursor") && (
        <TablePager
          mode={paginationMode}
          page={query.page}
          pageSize={query.pageSize}
          rowsOnPage={rowModel.length}
          total={total}
          hasMore={hasMore}
          pageSizes={pageSizes}
          onPageChange={(page) =>
            pushQuery({
              ...query,
              page,
              cursor: paginationMode === "cursor" ? (nextCursor ?? null) : null,
            })
          }
          onPageSizeChange={(pageSize) => pushQuery({ ...query, pageSize, page: 0, cursor: null })}
        />
      )}
    </div>
  )
}