Search documentation

Search for a page or heading...

tablecn
0

Client mode for rows already in the browser, server mode for a backend that filters, sorts and pages.

useDataTable has two modes. The difference is who does the work: the browser, or your backend.

Client mode (default)Server mode
dataevery rowone page, already filtered and sorted
Filter, sort, pagesrun in the browserdone by the backend
Row countdata.length after filteringrowCount from the backend
Use it forup to a few thousand rowsanything bigger, or data you can't send whole

Client mode

Pass every row. The table filters, searches, sorts and pages them in the browser:

components/orders-table.tsx
"use client"

import { useDataTable } from "@querycn/table-react"

export function OrdersTable({ orders }: { orders: Order[] }) {
  const table = useDataTable({
    data: orders,
    columns: orderColumns,
    getRowId: (order) => order.id,
  })
  // render <DataTable table={table} />…
}

data must keep its identity between renders (state, a query result, a useMemo), as for any TanStack table. To tune how rows are matched (nested values, which columns the search looks in), see Filtering.

Server mode

With mode: "server", data is the current page and rowCount is the total across pages. rowCount is undefined while it's unknown, such as during the first fetch. The pagination then shows the page without a total, and a full page means another one may follow.

It takes three steps:

  1. Choose the filter's format with a serializer on the provider.
  2. Build the request with useTableQuery: it reads the filter, search, sort and page from the URL and returns your backend's params.
  3. Fetch, then pass the page to useDataTable with mode: "server".
app/orders/orders-page.tsx
"use client"

import { djangoSerializer } from "@querycn/filter-core"
import { NextFilterProvider } from "@querycn/filter-next"
import { resetPagePatch } from "@querycn/table-react"

const filterSerializer = djangoSerializer()

export function OrdersPage() {
  return (
    <NextFilterProvider
      fields={orderFields}
      serializer={filterSerializer}
      onApply={() => resetPagePatch()}
    >
      <OrdersTable />
    </NextFilterProvider>
  )
}
app/orders/orders-table.tsx
"use client"

import { toSearchParams } from "@querycn/filter-core"
import { djangoTableParams, useDataTable, useTableQuery } from "@querycn/table-react"
import { keepPreviousData, useQuery } from "@tanstack/react-query"

import { DataTable } from "@/components/data-table/data-table"
import { DataTablePagination } from "@/components/data-table/data-table-pagination"

const tableParams = djangoTableParams()
const NO_ORDERS: Order[] = []

export function OrdersTable() {
  const { params, queryKey } = useTableQuery({ columns: orderColumns, serializer: tableParams })
  const orders = useQuery({
    queryKey: ["orders", queryKey],
    queryFn: () => fetch(`/api/orders?${toSearchParams(params)}`).then((r) => r.json()),
    placeholderData: keepPreviousData,
  })

  const table = useDataTable({
    mode: "server",
    columns: orderColumns,
    data: orders.data?.results ?? NO_ORDERS,
    rowCount: orders.data?.count,
    getRowId: (order) => order.id,
  })

  return (
    <>
      <DataTable
        table={table}
        isLoading={orders.isFetching}
        isError={orders.isError}
        onRetry={() => orders.refetch()}
      />
      <DataTablePagination table={table} />
    </>
  )
}

Good to know:

  • NO_ORDERS is a module constant: ?? [] inline would be a new array on every render.
  • keepPreviousData keeps the old page on screen while the next one loads. isLoading shows skeleton rows when there are none yet, and dims the old rows otherwise.
  • Pass useTableQuery the same columns, url and enableSorting as useDataTable, so both read the URL the same way.
  • The provider's serializer must return query params (djangoSerializer, jsonApiSerializer, postgrestSerializer all do). useTableQuery throws if it gets something else, such as a request body.
  • Without a FilterProvider, pass the table's adapter to both hooks.
  • The filter's params and the table's are merged into one object. When both write the same key (a filter field named search with djangoTableParams(), say), the table's wins and useTableQuery warns in the console. Rename the field or the table param (searchParam, orderingParam…).

queryKey is a string that changes only when the filter, search, sort or page does. Use it as the react-query or SWR key.

Sort, page and search params

The table's part of the request comes from a TableParamsSerializer. Three presets are included:

PresetOutput for sort -amount,name, page 2 of 20, search ann
jsonApiTableParams()sort=-amount,name&page[number]=2&page[size]=20&filter[search]=ann
djangoTableParams()ordering=-amount,name&page=2&page_size=20&search=ann
postgrestTableParams({ searchColumns: ["name", "email"] })order=amount.desc,name.asc&limit=20&offset=20&and=(or(name.ilike.*ann*,email.ilike.*ann*))

djangoTableParams takes orderingParam, pageParam, pageSizeParam and searchParam, to match your DRF OrderingFilter, pagination class and SearchFilter. PostgREST returns the total in Content-Range when you send the Prefer: count=exact header.

Searching

The search is sent only when there is one.

  • JSON:API has no standard search param; filter[search] is a common choice. Change it with searchParam.
  • Django REST framework: search is what SearchFilter reads, over its search_fields.
  • PostgREST has no search param, so the preset builds an ilike over the backend columns in searchColumns: each word must appear in one of them, as in client mode. It goes in and=(…) so it doesn't clash with the or=(…) a filter joined by OR writes. Without searchColumns, the search isn't sent. ilike minds accents unless your database uses unaccent; for full-text search, write your own serializer with PostgREST's fts operator.

All three take sortField, for when column ids aren't the backend's field names:

Field names
const tableParams = djangoTableParams({
  sortField: (columnId) => columnId.replaceAll("_", "__"), // customer_name → customer__name
})

Your own

A serializer is a function from the table's URL state to query params:

lib/table-params.ts
import type { TableParamsSerializer } from "@querycn/table-react"

export const tableParams: TableParamsSerializer = ({ sorting, pagination, search }) => ({
  ...(search && { query: search }),
  ...(sorting.length > 0 && {
    sortBy: sorting.map((s) => s.id).join(","),
    sortDir: sorting.map((s) => (s.desc ? "desc" : "asc")).join(","),
  }),
  skip: String(pagination.pageIndex * pagination.pageSize),
  take: String(pagination.pageSize),
})

Create it once, outside the component.

Rendering on the server

In a Next.js server component, read the same URL with the server entry points and fetch before the page renders.

Column definitions usually live in a client module (cells render JSX, and createSelectionColumn is a client component), so the server can't call tableUrlOptions(columns). Put the URL options in a plain module instead, with the ids users may sort by, and give the same object to both sides:

lib/order-table-url.ts
import type { TableUrlOptions } from "@querycn/table-react/server"

export const orderTableUrl: TableUrlOptions = {
  sortableColumns: ["customer", "status", "amount", "createdAt"],
  defaultSorting: [{ id: "createdAt", desc: true }],
}
app/orders/page.tsx
import { djangoSerializer } from "@querycn/filter-core"
import { parseFilters } from "@querycn/filter-next/server"
import { djangoTableParams, parseTableParams } from "@querycn/table-react/server"

import { orderFields } from "@/lib/order-fields"
import { orderTableUrl } from "@/lib/order-table-url"

export default async function Page(props: PageProps<"/orders">) {
  const searchParams = await props.searchParams
  const filter = parseFilters(searchParams, { fields: orderFields })
  const table = parseTableParams(searchParams, orderTableUrl)

  const params = {
    ...djangoSerializer()(filter, { fields: orderFields }),
    ...djangoTableParams()(table),
  }
  const orders = await getOrders(params) // your data access
  // pass orders.results and orders.count to a client component with useDataTable
}

On the client, pass url={orderTableUrl} to useDataTable (and to useTableQuery). parseTableParams decodes exactly as the client does, so the first render matches after hydration. When the page reads searchParams like this, leave shallow off the NextFilterProvider, so a new filter, sort or page asks the server for new rows.

Selection

Row selection holds within one page. It clears when the page, sort or filter changes, since the selected rows may no longer be on screen. getRowId keeps the selection across refetches of the same page.