Skip to content

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

  • antd
  • lodash (for debounce)
  • react-router (for useSearchParams)
  • 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.