Skip to contentVibraUI
Foundation

useServerTable

A DataTable instance over a query a server answers one page at a time: the pager, the sort and the filters rewrite one query, and only the newest answer lands — a re-read after a mutation included.

The wiring every server-paged view in the kit wrote out by hand, written once, and CONVENTIONS block rule 12 to the letter. apply() merges a change into the query held in a ref, so a second call in the same tick builds on the first, and goes back to page 1 unless the change is a page. pending is plain state from a question until its answer lands, never a transition awaited inside startTransition. The ref, not the order the promises settle in, decides which answer is shown: an answer to a question the reader has moved past is dropped. refresh() asks the newest query again as a question of its own, so an answer still in flight cannot overwrite what the server says now. When the newest question fails, pending clears, error says why, and the rows on show stay the last good page. refresh() resolves with its answer once it lands, or with undefined when a newer question took its place, so a mutation can re-read and then read its row out of what came back — through the same newest-wins path, where a re-read can never land over a newer answer. An answer that carries more than a page — the counts beside a filter, the figures above the table — names its page with pageOf and comes back whole as answer; queryOf takes the query as the server settled it (an order it filled in, a page it clamped) as the one the controls show and the next change builds on; and onApply hears each change the reader asks for, before it is asked, to drop what belonged to the last question — a notice, a selection. apply and refresh are the same functions for the table's life, and columns can be a function of them: a row menu's mutation then re-reads through the table it sits in, with no ref to close the loop. Draw the rows with DataTableRows, busy from pending, and the pager with DataTablePagination; both read table. The query also lives in the address: each change is written to the search params with replaceState — only what differs from initialQuery, every other param left alone, no history entry of its own — and a table that mounts on an address carrying a query asks for it, so a reader who filters the accounts, opens one and comes back finds the list as they left it. The order book, the invoices, the accounts, the hotel's bookings and guests and the settings audit log run on it.

Install

npx shadcn@latest add @vibra/use-server-table

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
initialQueryTQuery extends { page: number; pageSize: number; sort?: { id: string; desc?: boolean } }—The query the first page answers; add your own filters to it.
initialPageTAnswer = ServerPage<TData>—The first answer, read on the server: a page — rows, total, page and pageSize — or an answer pageOf reads one from.
fetchPage(query: TQuery) => Promise<TAnswer>—Asks for the answer a query describes — a server action, usually.
columnsDataTableColumnDef<TData>[] | ((controls: { apply, refresh }) => DataTableColumnDef<TData>[])—Memoised, as for DataTable. A row menu whose actions change the server and re-read the page builds them from the table's own controls — a function of { apply, refresh }, memoised too — since the table cannot exist before its columns do.
getRowId(row: TData) => string—Keeps a row's identity across pages.
defaultSortTQuery["sort"]—The order a header falls back to when its sort is cleared.
noun[singular: string, plural: string]["row", "rows"]What a row is, for the summary line.
tableOptionsPartial<TableOptions>—Anything else useTable takes — row selection, a hidden column — under what the hook owns; a state here is merged with the page and the sort.
pageOf(answer: TAnswer) => ServerPage<TData>—Where the page is in an answer that carries more — counts, figures. Required when the answer is not itself a page; initialPage and fetchPage then take and give the whole answer.
queryOf(answer: TAnswer) => TQuery—The query as the server settled it; the newest answer's becomes the query the controls show and the next change builds on. A header whose query left the order to the server shows the order the rows came in.
onApply(patch: Partial<TQuery>) => void—Hears each change the reader asks for — a filter, the sort, a page — before it is asked; refresh() does not call it.
syncUrlbooleantrueKeeps the query in the search params, and asks for the query an address carries when the table mounts. Turn it off for a second server table on one page.
returns.apply(patch: Partial<TQuery>) => void—Merges a change into the newest query and asks for it.
returns.refresh() => Promise<TAnswer | undefined>—Asks the newest query again as a question of its own; resolves with the answer once it lands, or undefined when a newer question took its place or it failed.
returns.answerTAnswer—The answer on show, whole: the page, and whatever pageOf reads it out of.
returns.pendingboolean—From a question until its answer lands; an older answer neither shows nor clears it.
returns.summarystring—Which rows are on show, in words: "13–24 of 240 entries"; range has the figures.

Dependencies

Source

hooks/use-server-table.ts
"use client"

import * as React from "react"
import { useTable, type PaginationState, type RowData, type SortingState, type Updater } from "@tanstack/react-table"

import { formatNumber } from "@/lib/format"
import {
  dataTableDefaultColumn,
  dataTableFeatures,
  type DataTableColumnDef,
  type DataTableFeatures,
  type DataTableInstance,
} from "@/components/ui/data-table"

/** One page of a query's answer, as the server sends it: the rows in hand and how many matched in all. */
export type ServerPage<TData> = { rows: TData[]; total: number; page: number; pageSize: number }

/** The part of a query the table reads and writes: which page, how long, in what order. */
export type ServerTableQuery = { page: number; pageSize: number; sort?: { id: string; desc?: boolean } }

type TableOptions<TData extends RowData> = Parameters<typeof useTable<DataTableFeatures, TData>>[0]

/** What a column def's row actions can reach: the table's own question-asking, for a change that needs the page read again. */
export type ServerTableControls<TQuery extends ServerTableQuery, TAnswer> = {
  apply: (patch: Partial<TQuery>) => void
  refresh: () => Promise<TAnswer | undefined>
}

/** What the hook owns and the caller cannot pass: the rows, the paging and the sort. */
type Owned = "features" | "columns" | "data" | "rowCount" | "manualPagination" | "manualSorting" | "manualFiltering" | "onPaginationChange" | "onSortingChange"

/**
 * Where the page is in an answer. An answer that is a page needs no `pageOf`;
 * one that carries more — the counts beside a filter, the figures above the
 * table — has to say where its page is.
 */
type PageSource<TData, TAnswer> = [TAnswer] extends [ServerPage<TData>]
  ? { pageOf?: (answer: TAnswer) => ServerPage<TData> }
  : { pageOf: (answer: TAnswer) => ServerPage<TData> }

export type UseServerTableOptions<
  TData extends RowData,
  TQuery extends ServerTableQuery,
  TAnswer = ServerPage<TData>,
> = {
  /** The query the first answer answers. */
  initialQuery: TQuery
  /** The first answer, read on the server: a page, or an answer `pageOf` reads a page from. */
  initialPage: TAnswer
  /** Asks the server for the answer a query describes — a server action, usually. */
  fetchPage: (query: TQuery) => Promise<TAnswer>
  /**
   * The query as the server settled it — a default it filled in, a page it
   * clamped to the last one. With it, the answer to the newest question also
   * becomes the query the controls show and the next change builds on.
   */
  queryOf?: (answer: TAnswer) => TQuery
  /**
   * Called with each change the reader asks for — a filter, the sort, a page —
   * before it is asked, to drop what belonged to the last question: a notice,
   * a selection of rows about to be replaced. `refresh()` does not call it.
   */
  onApply?: (patch: Partial<TQuery>) => void
  /**
   * Memoise them, as for DataTable. A row menu whose actions change the server
   * and read the page again builds them from the table's own controls — a
   * function of `{ refresh, apply }`, memoised the same way — since the table
   * cannot exist before its columns do.
   */
  columns: DataTableColumnDef<TData>[] | ((controls: ServerTableControls<TQuery, TAnswer>) => DataTableColumnDef<TData>[])
  getRowId: (row: TData) => string
  /** The order a header falls back to when its sort is cleared. Without one the query is left unsorted. */
  defaultSort?: TQuery["sort"]
  /** What a row is, for the summary line: `["entry", "entries"]`. */
  noun?: readonly [singular: string, plural: string]
  /**
   * Anything else useTable takes — row selection, a hidden column — spread
   * under what this hook owns. A `state` here is merged with the page and the
   * sort, which stay the hook's.
   */
  tableOptions?: Partial<Omit<TableOptions<TData>, Owned>>
  /**
   * Keep the query in the address (on by default): each change is written to
   * the search params, and a table that mounts on an address that carries one
   * asks for it — so a reader who filters, opens a row's page and comes back
   * finds the list as they left it. Only what differs from `initialQuery` is
   * written, and every other param on the address is left alone. Turn it off
   * for a second server table on one page.
   */
  syncUrl?: boolean
} & PageSource<TData, TAnswer>

export type ServerTable<TData extends RowData, TQuery extends ServerTableQuery, TAnswer = ServerPage<TData>> = {
  table: DataTableInstance<TData>
  /** The query the reader most recently asked for — what the controls show. */
  query: TQuery
  /** The answer on show, whole: the page and whatever came with it. */
  answer: TAnswer
  /** The page on show: the answer to the newest question that has come back. */
  page: ServerPage<TData>
  /** From a question until its answer lands. An answer to an older question neither shows nor clears it. */
  pending: boolean
  /** Why the newest question failed, if it did. The page on show is the last one that came back. */
  error: unknown
  /** Merges a change into the newest query, back to page 1 unless the change is a page, and asks for it. */
  apply: (patch: Partial<TQuery>) => void
  /**
   * Asks again for the newest query, as a question of its own — after the
   * server changed something. Resolves with the answer once it lands, or with
   * undefined when a newer question took its place, or it failed.
   */
  refresh: () => Promise<TAnswer | undefined>
  /** Which rows are on show: `{ first: 13, last: 24, total: 240 }`. */
  range: { first: number; last: number; total: number }
  /** The range in words: "13–24 of 240 entries". */
  summary: string
}

const resolve = <T,>(updater: Updater<T>, current: T): T =>
  typeof updater === "function" ? (updater as (old: T) => T)(current) : updater

/** The keys a query is written under: the ones the first query has, and the sort, which a query may leave out. */
const keysOf = (initial: ServerTableQuery) => [...new Set([...Object.keys(initial), "sort"])]

const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)

/**
 * `search` with `query` written into it: every key that differs from
 * `initial`, and none that does not, so the address of a list left as it
 * opened carries nothing. Params the query does not own are kept.
 */
export function queryToSearch<TQuery extends ServerTableQuery>(query: TQuery, initial: TQuery, search = ""): string {
  const params = new URLSearchParams(search)
  for (const key of keysOf(initial)) {
    params.delete(key)
    const value = (query as Record<string, unknown>)[key]
    if (same(value, (initial as Record<string, unknown>)[key])) continue
    if (key === "sort") {
      const sort = value as ServerTableQuery["sort"]
      params.set(key, sort ? `${sort.id}.${sort.desc ? "desc" : "asc"}` : "")
    } else if (Array.isArray(value)) {
      if (value.length === 0) params.set(key, "")
      for (const entry of value) params.append(key, String(entry))
    } else if (value !== undefined) {
      params.set(key, String(value))
    }
  }
  const written = params.toString()
  return written ? `?${written}` : ""
}

/**
 * The query an address carries, read against `initial` — each key parsed as
 * the kind of value `initial` holds there, anything unreadable left as it
 * opened — or undefined when the address carries none of it.
 */
export function queryFromSearch<TQuery extends ServerTableQuery>(search: string, initial: TQuery): TQuery | undefined {
  const params = new URLSearchParams(search)
  const keys = keysOf(initial).filter((key) => params.has(key))
  if (keys.length === 0) return undefined
  const query: Record<string, unknown> = { ...initial }
  for (const key of keys) {
    const raw = params.get(key) ?? ""
    const template = (initial as Record<string, unknown>)[key]
    if (key === "sort") {
      const match = raw.match(/^(.+)\.(asc|desc)$/)
      query.sort = raw === "" ? undefined : match ? { id: match[1], desc: match[2] === "desc" } : { id: raw, desc: false }
    } else if (Array.isArray(template)) {
      query[key] = params.getAll(key).filter((entry) => entry !== "")
    } else if (typeof template === "number") {
      const number = Number(raw)
      if (Number.isSafeInteger(number) && number >= 1) query[key] = number
    } else if (typeof template === "boolean") {
      query[key] = raw === "true"
    } else {
      query[key] = raw
    }
  }
  return query as TQuery
}

type Caller<TQuery, TAnswer> = {
  fetchPage: (query: TQuery) => Promise<TAnswer>
  queryOf?: (answer: TAnswer) => TQuery
  onApply?: (patch: Partial<TQuery>) => void
}

/**
 * The newest-wins question-asker behind the hook, CONVENTIONS block rule 12:
 * the query held outside React state, which a second change in the same tick
 * has not seen yet; `pending` a plain state; and each answer checked against
 * the question most recently asked, by identity, so only the answer to that
 * very object is shown — whatever order the answers land in.
 */
function createAsker<TQuery extends ServerTableQuery, TAnswer>(
  initialQuery: TQuery,
  set: {
    query: (query: TQuery) => void
    answer: (answer: TAnswer) => void
    pending: (pending: boolean) => void
    error: (error: unknown) => void
  },
  initialCaller: Caller<TQuery, TAnswer>
) {
  let latest = initialQuery
  let caller = initialCaller

  function ask(next: TQuery): Promise<TAnswer | undefined> {
    latest = next
    set.pending(true)
    const { fetchPage, queryOf } = caller
    return fetchPage(next).then(
      (fresh) => {
        if (latest !== next) return undefined
        // The server's own reading of the question is the one the next
        // change builds on.
        if (queryOf) set.query((latest = queryOf(fresh)))
        set.answer(fresh)
        set.error(null)
        set.pending(false)
        return fresh
      },
      (reason: unknown) => {
        if (latest !== next) return undefined
        set.error(reason)
        set.pending(false)
        return undefined
      }
    )
  }

  return {
    use: (next: Caller<TQuery, TAnswer>) => {
      caller = next
    },
    apply: (patch: Partial<TQuery>) => {
      caller.onApply?.(patch)
      const next = { ...latest, ...patch, page: patch.page ?? 1 }
      set.query(next)
      void ask(next)
    },
    // A copy, so it is a question of its own: an answer still in flight for
    // the same query was asked before whatever made this refresh necessary.
    refresh: () => ask({ ...latest }),
  }
}

/**
 * A DataTable instance over a query a server answers one page at a time — the
 * wiring every server-paged view in the kit wrote out by hand, written once.
 *
 * The pager, the sort and whatever else the page puts in its query (a search,
 * a facet) rewrite one query object, and the server is asked for the page it
 * describes. This follows CONVENTIONS block rule 12 to the letter: `apply()`
 * merges into the query held outside React state, not the `query` state
 * (which a second call in the same tick has not seen yet); `pending` is a
 * plain state, never a transition awaited inside `startTransition` — under
 * load that resolves after a test's act() scope has closed; and the newest
 * question, not the order the promises settle in, decides which answer lands,
 * so an answer to a question the reader has since moved past is dropped. A
 * re-read after a mutation is `refresh()`, the same path, so it can neither
 * overwrite a newer answer nor be overwritten by an older one. Draw the rows
 * with DataTableRows (busy from `pending`) and the pager with
 * DataTablePagination; both read `table`.
 */
export function useServerTable<TData extends RowData, TQuery extends ServerTableQuery, TAnswer = ServerPage<TData>>(
  options: UseServerTableOptions<TData, TQuery, TAnswer>
): ServerTable<TData, TQuery, TAnswer> {
  const { initialQuery, initialPage, getRowId, defaultSort, noun = ["row", "rows"], tableOptions, syncUrl = true } = options
  const pageOf = (options.pageOf ?? ((answer: TAnswer) => answer as unknown as ServerPage<TData>)) as (
    answer: TAnswer
  ) => ServerPage<TData>
  const [query, setQuery] = React.useState(initialQuery)
  const [answer, setAnswer] = React.useState(initialPage)
  const [pending, setPending] = React.useState(false)
  const [error, setError] = React.useState<unknown>(null)
  // Made once, so `apply` and `refresh` are the same functions for the table's
  // life: a column def or a callback that holds them is never rebuilt for them.
  const [asker] = React.useState(() =>
    createAsker<TQuery, TAnswer>(
      initialQuery,
      { query: setQuery, answer: setAnswer, pending: setPending, error: setError },
      { fetchPage: options.fetchPage, queryOf: options.queryOf, onApply: options.onApply }
    )
  )
  // The caller's functions as of the last commit: an answer is asked for, and
  // read, with the ones the page renders with now.
  React.useLayoutEffect(() => {
    asker.use({ fetchPage: options.fetchPage, queryOf: options.queryOf, onApply: options.onApply })
  })
  const { apply, refresh } = asker

  // The address holds the query. On the first commit it is read — a table
  // mounted on an address that carries a query (the list a reader comes back
  // to from a row's page) asks for that query — and on every later one the
  // query is written back with replaceState, which the app router follows
  // without a round trip and which adds no history entry of its own.
  const addressRead = React.useRef(false)
  React.useEffect(() => {
    if (!syncUrl) return
    if (!addressRead.current) {
      addressRead.current = true
      const carried = queryFromSearch(window.location.search, initialQuery)
      if (carried && !same(carried, query)) apply(carried)
      return
    }
    const search = queryToSearch(query, initialQuery, window.location.search)
    if (search === window.location.search) return
    window.history.replaceState(null, "", `${window.location.pathname}${search}${window.location.hash}`)
  }, [query, syncUrl, initialQuery, apply])

  const columnsOption = options.columns
  const columns = React.useMemo(
    () => (typeof columnsOption === "function" ? columnsOption({ apply, refresh }) : columnsOption),
    [columnsOption, apply, refresh]
  )

  const page = pageOf(answer)
  const pagination: PaginationState = { pageIndex: page.page - 1, pageSize: page.pageSize }
  // A question that left the order to the server shows the order its answer came in.
  const sort = query.sort ?? options.queryOf?.(answer).sort
  const sorting: SortingState = sort ? [{ id: sort.id, desc: sort.desc ?? false }] : []

  const table = useTable({
    features: dataTableFeatures,
    columns,
    data: page.rows,
    defaultColumn: dataTableDefaultColumn,
    enableRowSelection: false,
    getRowId,
    ...tableOptions,
    // The repository has already filtered, sorted and paged these rows.
    manualPagination: true,
    manualSorting: true,
    manualFiltering: true,
    rowCount: page.total,
    state: { ...tableOptions?.state, pagination, sorting },
    onPaginationChange: (updater) => {
      const next = resolve(updater, pagination)
      if (next.pageSize !== pagination.pageSize) apply({ page: 1, pageSize: next.pageSize } as Partial<TQuery>)
      else apply({ page: next.pageIndex + 1 } as Partial<TQuery>)
    },
    onSortingChange: (updater) => {
      const [first] = resolve(updater, sorting)
      apply({ sort: first ? { id: first.id, desc: first.desc } : defaultSort } as Partial<TQuery>)
    },
  })

  const first = page.total === 0 ? 0 : (page.page - 1) * page.pageSize + 1
  const last = Math.min(page.page * page.pageSize, page.total)
  const summary = `${formatNumber(first)}–${formatNumber(last)} of ${formatNumber(page.total)} ${page.total === 1 ? noun[0] : noun[1]}`

  return { table, query, answer, page, pending, error, apply, refresh, range: { first, last, total: page.total }, summary }
}