Data tables
TanStack Table on top of TanStack Query, with one shared section component, server-side pagination, and filters kept in the URL.
From: the CMS dashboard behind the company website. It has 69 list
tables (blogs, FAQs, gallery, products, users, inquiries and every list-type
website section), and I built 67 of them. A teammate set up the shared table
components they sit on: the useDataTable hook, column headers, pagination
and view options. I did the same on the resort website's admin dashboard, on
a base a teammate set up there too.
The layers
Each table is split into four parts, and each part owns one thing:
| Layer | File | Owns |
|---|---|---|
| Page content | blogs-page-content.tsx | The table's state, through a URL state hook |
| Feature table | blogs-table.tsx | Which query, which columns, which filters, which copy |
| Section | data-table-section.tsx | Fetching, pagination, and the loading, error and empty states |
| Shared table components | data-table.tsx and the rest of data-table/ | Rendering: header, rows, pagination controls |
One component for the query and its states
The first six tables (gallery, blogs, courses, SEO, products and users) each
had about 90 lines of the same scaffolding: run the query, convert the
pagination, then choose between a skeleton, an error with a retry button, an
empty state and the table. I moved that into one component,
DataTableSection, in a single change: 254 lines added and 540 removed across
the seven files.
export function DataTableSection({
queryOptions,
columns,
page,
pageSize,
onPaginationChange,
filters,
skeleton,
copy,
}: DataTableSectionProps) {
const { data, isLoading, isError, refetch } = useQuery({
...queryOptions,
placeholderData: keepPreviousData,
});
// TanStack Table counts pages from 0; the API counts from 1.
const pagination = useMemo(() => ({ pageIndex: page - 1, pageSize }), [page, pageSize]);
const table = useDataTable({
data: data?.records ?? [],
columns,
pageCount: data?.totalPages,
rowCount: data?.totalRecords,
manualPagination: true,
pagination,
onPaginationChange: (updater) => {
onPaginationChange(typeof updater === 'function' ? updater(pagination) : updater);
},
});
let body: ReactNode;
if (isError) {
body = <QueryErrorFallback {...copy.error} onRetry={refetch} />;
} else if (isLoading) {
body = skeleton;
} else {
body = <DataTableContent emptyState={<DataTableEmpty {...copy.empty} />} />;
}
return (
<DataTable table={table}>
<DataTableHeader>{filters}</DataTableHeader>
<DataTableBody>{body}</DataTableBody>
{isError || isLoading ? null : (
<DataTableFooter>
<DataTablePagination />
</DataTableFooter>
)}
</DataTable>
);
}manualPagination: truetells TanStack Table the server has already paginated the rows. Without it, the table would slice the 10 rows it was given into pages of its own.pageCountandrowCountcome from the API so the pagination controls know how many pages exist.keepPreviousDatakeeps the current page on screen while the next one loads, instead of flashing the skeleton. See Search and pagination.filtersis a slot. Search boxes, status filters and the "Create" button stay in the feature, because every table's filters are different.
A feature table is now configuration
With the section component in place, a whole table is a list of props:
export function BlogsTable({ queryParams, page, pageSize, onPaginationChange, ...filters }: BlogsTableProps) {
const blogs = BLOGS_ADMIN_COPY;
const columns = useBlogsTableColumns();
return (
<DataTableSection
columns={columns}
copy={{
empty: blogs.table.empty,
error: { title: blogs.errors.title, message: blogs.errors.message, retryLabel: blogs.errors.retry },
}}
filters={<BlogsTableFilters {...filters} />}
onPaginationChange={onPaginationChange}
page={page}
pageSize={pageSize}
queryOptions={cmsBlogListQueryOptions(queryParams)}
skeleton={<BlogsTableSkeleton />}
/>
);
}That's what made 67 tables manageable. A new table is a columns hook, a filters component, a skeleton and this file, and its loading and error behaviour can't drift from the others.
Filters and pagination in the URL
Every table keeps its search text, filters, page and page size in the URL with nuqs. The parsers are declared once per table:
export const blogsTableSearchParams = {
search: parseAsString.withDefault(''),
status: parseAsArrayOf(parseAsStringLiteral(BLOG_STATUSES)).withDefault([]),
page: parseAsInteger.withDefault(DEFAULT_PAGE),
limit: parseAsInteger.withDefault(DEFAULT_PAGE_SIZE),
} as const;A state hook reads them and turns them into the API's params:
export function useBlogsTableState() {
const [{ search, status, page, limit }, setFilters] = useQueryStates(blogsTableSearchParams, {
clearOnDefault: true,
history: 'push',
});
const debouncedSearch = useDebouncedValue(search, DEFAULT_SEARCH_DEBOUNCE_MS);
// A new search or filter sends the table back to page 1.
// ...effect that calls setFilters({ page: DEFAULT_PAGE })
const queryParams = useMemo(
() => ({
search: debouncedSearch.trim() || undefined,
status: status.length > 0 ? status : undefined,
page,
limit,
}),
[debouncedSearch, status, page, limit],
);
return {
search,
setSearch: (value: string) => setFilters({ search: value }),
page,
pageSize: limit,
onPaginationChange: ({ pageIndex, pageSize }) => setFilters({ page: pageIndex + 1, limit: pageSize }),
queryParams,
// ...status filter getters and setters
};
}What the URL gives the people using the CMS:
| Option | Effect |
|---|---|
| State in the URL | A refresh, or a link sent to a colleague, opens the same page with the same filters |
history: 'push' | Back goes to the previous page of the table, not out of the dashboard |
clearOnDefault: true | Page 1 with no filters is just /dashboard/content/blogs, with no ?page=1&limit=10 |
queryParams in the query key | Each combination of filters is its own cache entry, so going back to a page you've seen is instant |
The input shows search straight away, but the query only sees
debouncedSearch (300 ms), so typing doesn't send a request per letter.
Shared defaults, one place
DEFAULT_PAGE, DEFAULT_PAGE_SIZE (10) and DEFAULT_SEARCH_DEBOUNCE_MS
(300) live in configs/data-table.constants.ts. A table overrides them only
when it really needs a different value.
Columns in a hook, wrapped in useMemo
export function useBlogsTableColumns(): ColumnDef<CmsBlogRecord>[] {
const blogs = BLOGS_ADMIN_COPY;
const { statusLabels } = useBlogFilterOptions();
return useMemo(
() => [
{
accessorKey: 'title',
header: ({ column }) => <DataTableColumnHeader column={column} title={blogs.table.columns.title} />,
cell: ({ row }) => (
<div className="flex max-w-72 flex-col">
<span className="truncate font-medium">{row.original.title}</span>
<span className="truncate text-muted-foreground text-xs">/{row.original.slug}</span>
</div>
),
},
// ...status with an inline publish toggle, dates, row actions
],
[statusLabels],
);
}The columns are a hook, not a constant, so they can read other hooks, like
the status labels here. useMemo keeps the same array between renders;
TanStack Table treats a new columns array as new columns and rebuilds the
table.
The rules
What every table follows
- Pagination happens on the server:
manualPagination, withpageCountandrowCountfrom the API. - State that someone might want to share or refresh lives in the URL.
- The query options come from
lib/query-options, so the table, its mutations and any invalidation use the same key. - Loading, error and empty states come from
DataTableSection, never from the feature.
After a create, update or delete, the mutation invalidates the resource's
all prefix, which refreshes every page and filter combination of the table
at once. See Mutations.
Last updated on