Table with Synced Pagination
An Ant Design table with pagination state synced to URL query params, debounced filter updates, and one-click Excel export.
What Problem This Solves
You need a data table that:
- Syncs page size and current page to the URL (shareable/bookmarkable)
- Debounces filter changes to avoid excessive API calls
- Provides a clean mapping from your API's offset/limit to Antd's pagination config
- Exports visible data to Excel with one click
Prerequisites
antdlodash(fordebounce)react-router(foruseSearchParams)xlsx(for Excel export)
Files
use-query-params.ts
ts
import { useSearchParams } from "react-router";
type HandleUpdateSearchParamsOptions = {
replace?: boolean;
};
export const useQueryParams = () => {
const [searchParams, setSearchParams] = useSearchParams();
const handleUpdateSearchParams = (
changes: Record<string, string | number | undefined>,
options?: HandleUpdateSearchParamsOptions,
) => {
setSearchParams((params) => {
if (options?.replace) {
params = new URLSearchParams();
}
Object.entries(changes).forEach(([key, value]) => {
if (!value) {
params.delete(key);
return;
}
params.set(key, String(value));
});
return params;
});
};
const getSearchParamsValue = (key: string): string | undefined => {
return searchParams.get(key) || undefined;
};
const clearSearchParams = (options?: { persistedParams?: Array<string> }) => {
if (options?.persistedParams?.length) {
setSearchParams((params) => {
Array.from(params.keys()).forEach((key) => {
if (!options.persistedParams?.includes(key)) {
params.delete(key);
}
});
return params;
});
return;
}
setSearchParams(() => new URLSearchParams());
};
return {
searchParams,
handleUpdateSearchParams,
getSearchParamsValue,
clearSearchParams,
};
};use-table-pagination.ts
ts
import { type PaginationProps } from "antd";
import debounce from "lodash/debounce";
import { useMemo } from "react";
import { useQueryParams } from "./use-query-params";
const defaultLimit = 10;
const debounceDuration = 500;
export const useTablePagination = () => {
const { searchParams, handleUpdateSearchParams } = useQueryParams();
const limit = useMemo(() => {
return Number(searchParams.get("pageSize") ?? defaultLimit);
}, [searchParams]);
const offset = useMemo(() => {
return (Number(searchParams.get("page") ?? 1) - 1) * limit;
}, [limit, searchParams]);
const current = useMemo(() => Math.ceil((offset + 1) / limit), [limit, offset]);
const handleChangePage: PaginationProps["onChange"] = (page, pageSize) => {
const isPageSizeUnchanged = pageSize === limit;
const updatedPage = isPageSizeUnchanged ? page : 1;
handleUpdateSearchParams({ page: updatedPage, pageSize });
};
const handleUpdateFilter = debounce<typeof handleUpdateSearchParams>(
(changes, options) => handleUpdateSearchParams({ ...changes, page: 1 }, options),
debounceDuration,
);
return {
limit,
offset,
current,
handleChangePage,
handleUpdateFilter,
};
};pagination.ts (mapper)
ts
import { type TablePaginationConfig } from "antd";
export const mapTableMetaToAntdPagination = (params: {
current: number;
limit: number;
total: number;
}): TablePaginationConfig => ({
current: params.current,
pageSize: params.limit,
total: params.total,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (total, range) => `${range[0]}-${range[1]} of ${total} items`,
});Usage
tsx
import { Table } from "antd";
import { useTablePagination } from "@/lib/hooks/use-table-pagination";
import { mapTableMetaToAntdPagination } from "@/lib/utils/table/pagination";
import { useQuery } from "@tanstack/react-query";
export function UserListPage() {
const { limit, offset, current, handleChangePage, handleUpdateFilter } = useTablePagination();
const { data, isLoading } = useQuery({
queryKey: ["users", { limit, offset }],
queryFn: () => fetchUsers({ limit, offset }),
});
const pagination = mapTableMetaToAntdPagination({
current,
limit,
total: data?.total ?? 0,
});
return (
<Table
columns={columns}
dataSource={data?.items}
loading={isLoading}
pagination={pagination}
onChange={(pagination) => handleChangePage(pagination.current!, pagination.pageSize!)}
/>
);
}How Filter Sync Works
When a filter input changes, call handleUpdateFilter instead of handleUpdateSearchParams:
tsx
<Search onChange={(e) => handleUpdateFilter({ search: e.target.value || undefined })} />This debounces the URL update by 500ms and automatically resets to page 1.
Variations
With TanStack Router
If using TanStack Router instead of react-router, replace useSearchParams with useSearch from @tanstack/react-router and adapt handleUpdateSearchParams to use navigate({ search: { ... } }).
Without Excel Export
Remove xlsx dependency if you don't need export. The core pattern (URL sync + pagination) works without it.