React Table with Server-Side Sorting and Paging
Updated 9 October 2026. I wired the code from the first version of this post to a real API, a SQLite database with 1,284 orders, and tested it in Chromium, Firefox and WebKit with oks-ui 1.4.1, TypeScript 7 in strict mode and Playwright 1.63. The sorting, paging and the cancelled-flag race fix all held. One thing the first version got wrong: the table showed no loading state when you changed page or sort. I also tested the API advice, with a result worth seeing. The details and a results table are below. Client-side sorting is perfect for a few hundred rows. With ten thousand orders in a database, you cannot send them all to the browser, so the server has to sort and page. oks-ui's Table supports both modes. This tutorial wires it to an API.
import "oks-ui/tokens.css"; // the design tokens every component stylesheet reads
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";
The finished table, sorted by customer, on page 2. Rows and count come from the API.
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. I checked the second one directly. I intercepted the request and answered with three rows in a deliberately unsorted order ("Zed", "Aaron", "Mia"), then clicked the Customer header. The table showed them in exactly that order, in all three browsers.
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);
const [loading, setLoading] = useState(false);
A sort descriptor is the column key and a direction: "ascending" or "descending". The loading flag is new in this version; section 3 explains why.
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),
});
setLoading(true);
fetch(`/api/orders?${params}`)
.then((r) => r.json())
.then((json) => {
if (!cancelled) {
setData(json);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [sort, page]);
The cancelled flag drops a slow, older response that arrives after a newer one. I tested what happens without it. I made the server answer page 2 in 900 ms and page 3 in 50 ms, then clicked page 2 and quickly page 3. With the flag, the table showed page 3's rows. Without it, the pagination said page 3 while the rows on screen were page 2's, in all three browsers. Because the count under the table was calculated from the page number, it still said "Showing 41–60", so nothing on screen gave the mistake away.
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={loading}
loadingRowCount={PAGE_SIZE}
/>
Three details matter:
sortDescriptormakes the header arrows show the current sort, even though the table is not sorting. With the descriptor set tocreatedAtdescending, the Date header reportedaria-sort="descending"and the others none. Clicking Customer sentsort=customer&dir=asc, and a second click sentdir=desc. A new column always starts ascending.Going back to page 1 on a new sort is what users expect; page 7 of a differently ordered list is meaningless. I went to page 3, clicked a header, and the request carried
page=1.The loading state: the first version was wrong. It passed
isLoading={!data}. That is true only before the first response. After thatdatais never empty again, so on every page or sort change the table kept showing the old rows with no sign anything was happening. I measured it with a slow request: 250 ms after clicking page 2 there were no skeleton rows and no busy marker, only the old rows. A separateloadingflag fixes it, andloadingRowCount={PAGE_SIZE}makes the skeleton as tall as a real page so the layout does not jump. With both, 20 skeleton rows appeared in all three browsers.
Left: the first version's isLoading={!data}. Right: a separate flag.
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>
)
}
/>
Pagination takes the total number of items plus the page size and works out the page count itself: 1,284 rows at 20 per page gave 65 pages, with the current page marked aria-current. PaginationSummary prints "Showing 21–40 of 1284" by default. Pass format to word it your own way, for example with thousands separators:
<PaginationSummary page={page} pageSize={PAGE_SIZE} total={data.total}
format={(from, to, total) => `Showing ${from.toLocaleString()}–${to.toLocaleString()} of ${total.toLocaleString()}`} />
That gave "Showing 21–40 of 1,284". Enter on a focused sort header and on a page button both worked.
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";
I built both versions against a throwaway SQLite database to see what the warning is about. In the unsafe version, the sort parameter goes straight into ORDER BY. A request can then put a condition about another table in it, for example "if the first letter of the secret in this other table is s, sort by total, otherwise sort by customer". The order of the rows that come back reveals the answer. With a guess of s the first rows were the cheapest orders, and with a guess of x they were sorted by customer. The safe version, given the same two requests, returned identical rows both times, because the unknown column falls back to the default. Nothing was deleted or damaged, and the data was my own, but it shows that a sort parameter can leak data from tables the endpoint was never meant to touch. For stable pages, also add a unique column such as id as a last sort key, for example ORDER BY customer ASC, id. Without one, rows that tie on the sort column can appear on two pages or on none, because databases do not promise an order among ties. In my SQLite test, 65 pages sorted by customer returned all 1,284 rows exactly once even without the tiebreaker, so I could not make it fail there. Other databases may behave differently, and the extra key costs nothing.
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 back on load. I tested a small version: every sort or page change did history.pushState, and the component read the query string and listened for popstate. After sorting by total and going to page 2, the address held sort=total&dir=asc&page=2. The Back button returned to page 1 with the same sort, and a reload restored page 2 and the Total arrow, in all three browsers. The table code does not change; only where sort and page come from.
Empty results
When a filter returns nothing, Table shows its emptyContent, which you can replace with something useful such as an explanation and a button to clear the filters. I tested this with a filter that matches nothing and the replacement text appeared. One thing to handle yourself: the footer in section 4 still showed "Showing 0–0 of 0" and page buttons, so hide bottomContent when data.total is 0.
Results
Every result, in all three browsers. On size: with React included, the page came to 113 KB of JavaScript and 22 KB of CSS gzipped.
Check your own paging
This script walks every page of your endpoint and tells you whether each row came back exactly once. It expects a response shaped like { rows, total }.
// node check-paging.mjs "http://localhost:3000/api/orders?sort=customer&dir=asc&pageSize=20" id
const [base, idField = "id"] = process.argv.slice(2);
const seen = new Map();
let total = 0;
for (let page = 1; ; page++) {
const { rows, total: t } = await (await fetch(`${base}&page=${page}`)).json();
total = t;
if (!rows.length) break;
for (const row of rows) seen.set(row[idField], (seen.get(row[idField]) ?? 0) + 1);
if (seen.size >= total) break;
}
const repeated = [...seen.values()].filter((n) => n > 1).length;
console.log(`${seen.size} distinct rows of ${total}; ${repeated} returned more than once`);
console.log(seen.size === total && repeated === 0 ? "paging is consistent" : "paging is NOT consistent: add a unique tiebreaker such as id to the sort");
On my SQLite endpoint it printed "1284 distinct rows of 1284; 0 returned more than once" with and without the id tiebreaker, so on that database it cannot tell the two apart. It is most useful against a database that does reorder ties. The data table pattern shows a complete table with search, selection and paging.
Limits
The API was a small Python server over an in-memory SQLite database with 1,284 rows. Real latency, a large table and a different database were not tested.
The injection test was a boolean-style probe against my own throwaway data. It shows the class of risk; it is not a security audit, and your framework or ORM may already protect you.
The URL-state version used
history.pushStatein plain React, not a router such as Next.js's.axe-core can only find some kinds of accessibility problem, and I did not test with a screen reader.
I tested in desktop browsers at 900 and 1100 pixels wide, not on a phone.
Originally published at oks-ui.com.
