Capítulo 10 de 54

Chapter 10: Client-Side vs Server-Side Guide

Core Idea

Every dataset-wide operation (filter, sort, group, paginate, aggregate) is either fully client-side (TanStack Table processes rows already in the browser) or fully server-side ("manual" — the table just holds state/APIs, your backend does the work) — and per the docs, you basically can't mix approaches across features for the same dataset without misleading results.

Key Concepts

  • Start client-side by default. TanStack Table's client-side row models are stress-tested at 1M rows and, after the Object Prototypes memory refactor, comfortably to 15M rows. "Large dataset" is not automatically a reason to go server-side — test with your real data/hardware first.
  • Go server-side when: the full dataset is slow/expensive/memory-heavy to fetch; the browser only ever needs a page/subset; permissions or business rules must be backend-enforced; data changes too fast for a full client copy to stay fresh; or the backend can filter/sort/aggregate more efficiently (indexes).
  • Don't mix scopes silently. If the server paginates, it must also own any filtering/sorting/grouping/aggregation that needs to apply across the whole result set — client-side sorting after server-side pagination only sorts the current page, and looks like a bug to users. Facet counts computed client-side from one server page describe only that page, not the full filtered set.
  • "Manual" = you supply already-processed data. A manual* flag doesn't fetch or transform anything — it tells the table "trust the data you gave me as already filtered/sorted/paginated for this feature." You can still register the feature (for its state/APIs) while omitting its row-model factory.

Reference Tables

OperationManual flagClient-side row model
Column/global filteringmanualFilteringfilteredRowModel
GroupingmanualGroupinggroupedRowModel
AggregationmanualAggregationlocal aggregationFn fallback
SortingmanualSortingsortedRowModel
ExpandingmanualExpandingexpandedRowModel
PaginationmanualPaginationpaginatedRowModel

Faceting supports server-provided results too, but via custom factories rather than a manual* flag (see Faceting Guide).

Code Examples

// server-side page/sort/filter driven by TanStack Query — the general shape
const features = tableFeatures({ columnFilteringFeature, globalFilteringFeature, rowPaginationFeature, rowSortingFeature })
// no filteredRowModel/sortedRowModel/paginatedRowModel: server does that work

const dataQuery = useQuery({
  queryKey: ['people', { sorting, globalFilter, pagination }],
  queryFn: () => fetchPeople({ sorting, globalFilter, pagination }),
  placeholderData: keepPreviousData,
})

const table = useTable({
  features, columns,
  data: dataQuery.data?.rows ?? [],
  rowCount: dataQuery.data?.rowCount,
  state: { sorting, globalFilter, pagination },
  onSortingChange: (u) => { setSorting(u); setPagination(p => ({ ...p, pageIndex: 0 })) },
  onGlobalFilterChange: (u) => { setGlobalFilter(u); setPagination(p => ({ ...p, pageIndex: 0 })) },
  onPaginationChange: setPagination,
  manualFiltering: true, manualSorting: true, manualPagination: true,
})
  • What it demonstrates: every server-owned state slice (sorting, filter, pagination) goes into the query key and gets a change handler that resets pageIndex back to 0 — otherwise you can land on a now-nonexistent page after a filter/sort change.

Worked Example

Why you must reset pagination by hand in the change handlers: with a fully manual (server-side) config, the client-side filtered/sorted row models — which normally trigger an automatic page-index reset as a side effect of recomputing — are never registered, so their auto-reset hooks never fire. manualPagination also disables autoResetPageIndex by default. The fix is always the same shape: reset pageIndex to 0 inside onSortingChange/onGlobalFilterChange/onColumnFiltersChange, not as an afterthought.

For cursor-based pagination (when a total row count is expensive or a full jump-to-page isn't needed), pair useInfiniteQuery with pageCount: -1 and derive canNextPage from whether the next cursor page is cached or the last response reported hasNextPagegetCanNextPage() can't know the answer on its own when the total is unknown, and getCanLastPage() always returns false in that mode since there's no finite last page to target.

Key Takeaways

  1. Client-side first, server-side only when you have a concrete reason — 1M+ rows is not automatically that reason.
  2. Registering a feature and enabling its client-side row model are two separate decisions; server-side tables register the feature, skip the row model, set the manual* flag.
  3. Always reset pageIndex in the change handlers for server-owned filter/sort/global-filter state under manualPagination — the automatic reset you'd get from a client-side row model doesn't fire.
  4. Use a stable getRowId for server-driven tables — page-relative row indexes don't reliably identify the same record across requests/responses.
  5. Virtualization and pagination solve different problems (render-visible-only vs. limit-total-loaded) — virtualizing doesn't reduce what you fetch or process.

Connects To

  • Features Guide: the feature/row-model split this guide builds on.
  • Table State (React) Guide: owning state slices for server-driven requests.
  • Prefetching & Router Integration (TanStack Query docs skill, if present): the query-side half of this pattern.