Server‑Driven Pagination, Sorting, and Filtering with Ant Design Table (v5)
Learn how to drive Ant Design Table pagination, sorting, and filtering entirely from the server using a controlled pagination object and the unified onChange callback. Includes a minimal React example, common pitfalls, and a verification checklist.
28 Jun 2026, 00:50 UTC

The problem: client‑side pagination doesn’t scale
Ant Design’s Table is convenient for small data sets, but the default behaviour loads the entire dataSource into the browser. When the API can return thousands of rows, that approach blows up memory, slows the initial render, and makes the UI feel sluggish. The fix is to let the server own pagination, sorting, and filtering while the Table only renders the current page.
Controlled pagination pattern
In Ant Design v5 the Table accepts a controlled pagination object:
current– the active page number (1‑based)pageSize– rows per pagetotal– total row count returned by the API
When total exceeds the length of dataSource, the Table renders its pagination UI but does not fetch data. The application decides when to request the next page, typically inside the onChange callback.
One callback to rule them all
onChange(pagination, filters, sorter) fires for three distinct user actions:
- Page change –
pagination.currentandpagination.pageSizeupdate. - Column sort –
sortercarries{ field, order }whereorderis'ascend','descend'orundefined. - Column filter –
filtersis a map of column keys to selected filter values.
Because all three arrive together, you can build a single request payload (e.g. ?page=2&size=20&sort=name:asc&status=active) and keep the component declarative.
Worked example: a minimal server‑driven Table
import React, { useState, useEffect } from 'react';
import { Table, Spin } from 'antd';
const columns = [
{ title: 'Name', dataIndex: 'name', key: 'name', sorter: true },
{ title: 'Age', dataIndex: 'age', key: 'age', sorter: true },
{ title: 'Status', dataIndex: 'status', key: 'status',
filters: [
{ text: 'Active', value: 'active' },
{ text: 'Inactive', value: 'inactive' },
],
filterMultiple: false,
onFilter: (value, record) => record.status === value,
},
];
export default function UserTable() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
const [sorter, setSorter] = useState({});
const [filters, setFilters] = useState({});
const fetchData = async () => {
setLoading(true);
const params = new URLSearchParams();
params.set('page', pagination.current);
params.set('size', pagination.pageSize);
if (sorter.field) params.set('sort', `${sorter.field}:${sorter.order === 'ascend' ? 'asc' : 'desc'}`);
Object.entries(filters).forEach(([key, value]) => {
if (value?.length) params.set(key, value[0]); // single‑select filters
});
try {
const res = await fetch(`/api/users?${params}`);
const json = await res.json(); // { items: [], total: 123 }
setData(json.items);
setPagination(prev => ({ ...prev, total: json.total }));
} finally {
setLoading(false);
}
};
useEffect(() => { fetchData(); }, [pagination.current, pagination.pageSize, sorter, filters]);
const handleTableChange = (newPagination, newFilters, newSorter) => {
// Reset to first page when sort or filter changes
if (newSorter.field !== sorter?.field || newSorter.order !== sorter?.order) {
setPagination(p => ({ ...p, current: 1 }));
}
if (JSON.stringify(newFilters) !== JSON.stringify(filters)) {
setPagination(p => ({ ...p, current: 1 }));
}
setPagination(newPagination);
setFilters(newFilters);
setSorter(newSorter);
};
return (
);
}
Key points in the snippet:
pagination.totalcomes from the API response, not fromdata.length.loadingis tied to the fetch state to avoid UI flashes.handleTableChangenormalises the three arguments and resetscurrentto1whenever sorting or filtering changes – a common oversight that otherwise leaves the user on an empty high‑numbered page.
Trade‑offs and gotchas
| Aspect | Benefit | Cost / Risk |
|---|---|---|
| Server‑side pagination | Constant memory footprint; works with millions of rows. | Extra round‑trip on every page/sort/filter change. |
Unified onChange |
Single data‑fetching logic, easier to test. | Must manually map sorter.order values to backend syntax. |
Controlled filters |
Backend can implement complex filter logic (e.g. full‑text search). | Column filter dropdowns are static – you must supply the full list of filter options up front. |
| Version drift | — | Ant Design v3→v4→v5 changed prop names (e.g. pagination vs page) and the sorter argument shape. Always verify against the installed version (package.json). |
Quick verification checklist
- Render the Table with a stubbed fetch; click page 2 and confirm
onChangereceives{ current: 2, pageSize: 10 }. - Toggle a column sorter; log the
sorterargument and ensurefieldmatches the column’sdataIndexandorderis'ascend'or'descend'. - Apply a filter while on page 3; the component should reset to page 1 and issue a request containing the filter key/value.
- Check
package.jsonforantdversion and cross‑reference the used props with that version’s official API docs.
Closing action
Adopt the controlled pagination + onChange pattern for any Table that may exceed a few hundred rows. Start by wiring a single fetchData function to the onChange handler, then add loading and page‑reset logic. This keeps the UI responsive, the bundle size predictable (v5’s CSS‑in‑JS adds ~30 KB gzipped), and the data‑fetching logic testable in isolation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.