Workbench

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:

LayerFileOwns
Page contentblogs-page-content.tsxThe table's state, through a URL state hook
Feature tableblogs-table.tsxWhich query, which columns, which filters, which copy
Sectiondata-table-section.tsxFetching, pagination, and the loading, error and empty states
Shared table componentsdata-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.

components/shared/data-table/data-table-section.tsx (simplified)
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: true tells 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. pageCount and rowCount come from the API so the pagination controls know how many pages exist.
  • keepPreviousData keeps the current page on screen while the next one loads, instead of flashing the skeleton. See Search and pagination.
  • filters is 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:

features/dashboard/blogs/components/blogs-table.tsx
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:

features/dashboard/blogs/constants/blogs-table-search-params.ts
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:

features/dashboard/blogs/hooks/use-blogs-table-state.ts (simplified)
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:

OptionEffect
State in the URLA 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: truePage 1 with no filters is just /dashboard/content/blogs, with no ?page=1&limit=10
queryParams in the query keyEach 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

features/dashboard/blogs/components/blogs-table-columns.tsx (simplified)
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, with pageCount and rowCount from 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

On this page