Skip to content
oks-ui

React Table with Server-Side Sorting and Paging

Tutorial · on · by oks-ui team

A data table with five rows, one highlighted, and pagination controls below it.
ShareXLinkedIn
On this page
  1. Client mode vs. server mode
  2. 1. State for sort and page
  3. 2. Fetch when either changes
  4. 3. The table in server mode
  5. 4. Pagination
  6. 5. The API side
  7. 6. Keep the state in the URL
  8. Empty results

Client-side sorting is perfect for a few hundred rows. With ten thousand orders in a database, you can't send them all to the browser — the server has to sort and page. oks-ui's Table supports both modes. This tutorial wires it to an API.

import { Table, type TableColumn, type TableSortDescriptor } from "oks-ui/table";
import "oks-ui/table.css";
import { Pagination, PaginationSummary } from "oks-ui/pagination";
import "oks-ui/pagination.css";

Client mode vs. server mode

Table decides by one prop:

  • Without `onSortChange`, clicking a sortable header sorts the rows you passed, in the browser.
  • With `onSortChange`, the table does not reorder anything. It reports the click and shows whatever order you give it. You fetch the sorted page from the server.

1. State for sort and page

const PAGE_SIZE = 20;

const [sort, setSort] = useState<TableSortDescriptor>({ column: "createdAt", direction: "descending" });
const [page, setPage] = useState(1);
const [data, setData] = useState<{ rows: Order[]; total: number } | null>(null);

A sort descriptor is the column key and a direction: "ascending" or "descending".

Advertisement

2. Fetch when either changes

useEffect(() => {
  let cancelled = false;
  const params = new URLSearchParams({
    sort: sort.column,
    dir: sort.direction === "ascending" ? "asc" : "desc",
    page: String(page),
    pageSize: String(PAGE_SIZE),
  });
  fetch(`/api/orders?${params}`)
    .then((r) => r.json())
    .then((json) => { if (!cancelled) setData(json); });
  return () => { cancelled = true; };
}, [sort, page]);

The cancelled flag drops a slow, older response that arrives after a newer one — otherwise fast clicking can show the wrong page.

3. The table in server mode

const columns: TableColumn<Order>[] = [
  { key: "id", header: "Order" },
  { key: "customer", header: "Customer", sortable: true },
  { key: "total", header: "Total", align: "end", sortable: true, render: (o) => formatMoney(o.total) },
  { key: "createdAt", header: "Date", sortable: true, render: (o) => formatDate(o.createdAt) },
];

<Table
  aria-label="Orders"
  columns={columns}
  rows={data?.rows ?? []}
  getRowKey={(o) => o.id}
  sortDescriptor={sort}
  onSortChange={(next) => {
    setSort(next);
    setPage(1);
  }}
  isLoading={!data}
/>

Two details matter:

  • sortDescriptor makes the header arrows show the current sort, even though the table isn't sorting.
  • Going back to page 1 on a new sort is what users expect; page 7 of a differently ordered list is meaningless.

4. Pagination

Put the result count and the page buttons under the table with bottomContent:

<Table
  /* …as above… */
  bottomContent={
    data && (
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
        <PaginationSummary page={page} pageSize={PAGE_SIZE} total={data.total} />
        <Pagination total={data.total} pageSize={PAGE_SIZE} page={page} onChange={setPage} />
      </div>
    )
  }
/>

PaginationSummary prints "Showing 21–40 of 1284"; pass format to word it your own way, for example with thousands separators.

5. The API side

Whatever your backend, the contract is small: accept a sort column, a direction, a page and a page size; return that page of rows and the total count. Only allow sorting by known columns — never pass the sort parameter straight into a database query.

const SORTABLE = new Set(["customer", "total", "createdAt"]);
const column = SORTABLE.has(req.query.sort) ? req.query.sort : "createdAt";

6. Keep the state in the URL

Users expect the back button and shared links to keep their place. Store sort, dir and page in the query string instead of component state, and read them on load. The table code stays the same.

Empty results

When a filter returns nothing, Table shows its empty state. Replace it with something useful through emptyContent, such as an explanation and a button to clear the filters.

The data table pattern shows a complete table with search, selection and paging.

Advertisement

oks-ui team

We build oks-ui — a React component library in strict TypeScript, themed entirely through CSS custom properties, with no runtime dependencies beyond React. These posts are what we learned building it.

Read the docs · All posts

Was this post helpful?

ShareXLinkedIn