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".
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:
sortDescriptormakes 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.


