Solve Any Data Table Problem using TanStack Table
Introduction
I’ve encountered many data table issue. TanStack Table is a awesome library that at first glance may seem overly complex but actually saves countless hours of time. I would strongly recommend it for any app that requires data table.
In work, by introducing TanStack Table, I was able to rebuild most complex functionalities of existing data table from scratch within a few days and began producing reusable, scalable, and concise data forms in bulk. This is due to its:
- Headless UI design, meaning it does not bind any styles, only provides state and logic, and can be integrated with any existing interface.
- Provides adapters for major frameworks (React, Vue, Angular).
- Free, open-source, and popular, with many examples and clear documentation, supporting TypeScript.
Complexity Behind Data Forms
Displaying data in a table isn’t that difficult, right? The simplest form just takes data → renders it on the screen → keeps the data updated, but there are actually many issues to consider, such as maintaining client and server state and logic (loading data, pagination, searching, sorting…) which can be quite a headache.
Due to lack of planning, one minor feature change (adjusting the number of items per page) once cost three day, resulting in shotgun surgery on hundreds of data forms, which was neither efficient nor reliable. As the project grows larger and functionalities increase, you find yourself building a “mini table framework,” and the code becomes tightly coupled with bugs constantly emerging.
Core Concepts of TanStack Table
At first glance, the following concepts and APIs may seem complex because it is intended to be a toolbox that requires configuration. Integrating with your own UI will require additional time and experience. For beginners, you can refer to resources like Shadcn’s excellent documentation and step-by-step example for integrating TanStack Table.
- Core Concepts:
- Data - The core data provided to the table
- Column Definitions: Objects used to configure a column and its data model, display templates, and more
- Table Instance: The core table object containing both state and API
- Row Models: How the data array is transformed into useful rows based on the features you are using
- Rows: Each row mirrors its respective row data and provides row-specific APIs
- Cells: Each cell mirrors its respective row-column intersection and provides cell-specific APIs
- Header Groups: Header groups are calculated slices of nested header levels, each containing a set of headers
- Headers: Each header is interrelated or derived from its column definitions and provides header-specific APIs
- Columns: Each column mirrors its respective column definition and provides column-specific APIs
- Features:
- Column Ordering - Dynamically change the order of columns
- Column Pinning - Pin columns (freeze) on the left or right side of the table
- Column Sizing - Dynamically change the width of columns (via resize control points)
- Column Visibility - Control the hiding or displaying of columns
- Expanding - Expand or collapse rows (supports sub-rows/hierarchical data)
- Column Faceting - List unique values in a column or minimum/maximum values of the column
- Column Filtering - Filter rows in a column based on search values
- Grouping - Group multiple columns together and perform aggregation
- Global Faceting - List unique values or minimum/maximum values of all columns in the table
- Global Filtering - Filter all rows based on search values across the whole table
- Pagination - Paginate row data
- Row Pinning - Pin rows (freeze) at the top or bottom of the table
- Row Selection - Select or deselect rows (commonly used for checkbox operations)
- Sorting - Sort rows based on column values
- Virtualization - Only render visible rows to improve performance
Practical Example
Taking the creation of a client-side data table: ClientDataTable.vue
as a case study, the most important thing is that it accepts columns
(column definitions) and data
(form data), and internally defines data states.
<ClientSideDataTable:columns="thingsColumns":data="thingsData":is-loading="isThingsFetching"> <template #header="{ rowSelection }" > <Button @click="() => handleActionA(rowSelection)" >Action A</Button> <Button>Action B</Button> <Button>Action C</Button> </template>
<template #filter="{ table }"> <DataTableViewOptions :table="table"/> <DataTableSearch :table="table" placeholder="Search field..." filtering-column="email" /> </template></ClientSideDataTable>
The client-side form will look somewhat like the following, reserving a header slot
to expose selection states and a filter slot
to expose form states:
<template> <div> <div v-if="$slots.header"> <!-- Function menu --> <slot name="header" :row-selection="rowSelection" /> </div> <div v-if="$slots.filter"> <!-- Filter menu --> <slot name="filter" :table="table" /> </div> <!-- Data table rendering --> <DataTable :table="table" :columns="columns" :is-loading="isLoading" /> </div> <!-- Data table pagination --> <DataTablePagination :table="table" /></template>
<script setup lang="ts" generic="TData, TValue">import DataTable from '@/components/ui/datatables/DataTable.vue'import { useVueTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, getExpandedRowModel, type SortingState, type ColumnFiltersState, type ColumnDef, type VisibilityState, type ExpandedState, type RowSelectionState,} from '@tanstack/vue-table'
import { valueUpdater } from '@/utils/dataTable'
const { data, columns, isLoading } = defineProps<{ columns: ColumnDef<TData, TValue>[]; data?: TData[]; isLoading: boolean;}>()
const sorting = ref<SortingState>([])const columnFilters = ref<ColumnFiltersState>([])const columnVisibility = ref<VisibilityState>({})const rowSelection = ref<RowSelectionState>({})const expanded = ref<ExpandedState>({})
const table = useVueTable({ get data() { return data ?? [] }, get columns() { return columns }, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getExpandedRowModel: getExpandedRowModel(), onSortingChange: (updaterOrValue) => valueUpdater(updaterOrValue, sorting), onColumnFiltersChange: (updaterOrValue) => valueUpdater(updaterOrValue, columnFilters), onColumnVisibilityChange: (updaterOrValue) => valueUpdater(updaterOrValue, columnVisibility), onRowSelectionChange: (updaterOrValue) => valueUpdater(updaterOrValue, rowSelection), onExpandedChange: (updaterOrValue) => valueUpdater(updaterOrValue, expanded), state: { get sorting() { return sorting.value }, get columnFilters() { return columnFilters.value }, get columnVisibility() { return columnVisibility.value }, get rowSelection() { return rowSelection.value }, get expanded() { return expanded.value }, },})</script>
The more complicated server-side form issue is that the state comes from outside the form. At this point, if you integrate TanStack Query through the previously mentioned article “Better Server State Management through TanStack Query!,” you can encapsulate the state along with the table’s pagination, sorting, and search logic into a useServerSideDataTable
Composable as follows:
interface UseServerSideDataTableOptions<TResult> { queryKey: QueryKey; queryFn: (params: { internalTableFilter: { limit: number; page: number; sortBy: string; sortDesc: boolean; search: string; }; }) => Promise<{ totalRows: number; results: TResult[];}>;}
export function useServerSideDataTable<TResult>( options: UseServerSideDataTableOptions<TResult>,) { const pagination = ref<PaginationState>({ pageIndex: 0, pageSize: 10 }) const sorting = ref<SortingState>([]) const columnFilters = ref<ColumnFiltersState>([]) const tableFilter = computed(() => { // Page index differences need to be converted const zeroToOneBasedIndexOffset = 1 const firstSortingId = sorting.value[0]?.id const firstSortDesc = sorting.value[0]?.desc // TanStack Table column fields might not always be strings; they could be numbers, dates, or menus, but we are likely only using string search, so assert as string const firstSearch = columnFilters.value[0]?.value as string return { limit: pagination.value.pageSize, page: pagination.value.pageIndex + zeroToOneBasedIndexOffset, sortBy: firstSortingId, sortDesc: firstSortDesc, search: firstSearch, } })
const query = useQuery({ queryKey: [...options.queryKey, pagination, sorting, columnFilters], queryFn: () => options.queryFn({ internalTableFilter: tableFilter.value, }), placeholderData: keepPreviousData, })
const pageCount = computed(() => { const queryData = query.data.value if (!queryData) return 0 const totalRows = queryData.totalRows const pageSize = pagination.value.pageSize const hasResults = queryData?.results?.length > 0 return calcPageCount(totalRows, pageSize, hasResults) })
return { // Table state available for ServerSideDataTable pagination, sorting, columnFilters, pageCount,
// TanStack Query response ...query, }}
This will allow you to wrap most of the logic needed for server-side forms and plug it into ServerSideDataTable.vue
.
const customSearchs = ref({ foo: 'abc', bar: '123'})
const { data: thingsData, isFetching: isThingsFetching, pageCount: thingsPageCount, pagination: thingsPagination, sorting: thingsSorting, columnFilters: thingsColumnFilters, refetch: thingsRefetch,} = useServerSideDataTable<Things>({ queryKey: ['things', customSearchs], queryFn: async ({ internalTableFilter, }) => {
const requestParams = { // Constructed TableFilter to form the final request ...internalTableFilter.value, ...customSearchs } satisfies ThingsFilter
return await fetchThings(requestParams) ; },})
<ServerSideDataTable :columns="thingsColumns" :data="thingsData" :is-loading="isThingsFetching" :page-count="thingsPageCount" v-model:pagination="thingsPagination" v-model:sorting="thingsSorting" v-model:column-filters="thingsColumnFilters"/>
Conclusion
For large, mature libraries like TanStack Table, AI has more likely knows about it. Many issues can be resolved by asking AI for a starting point and then checking the documentation.
Very satisfied with how easy it is to create data table now, the developer experience of this package is awesome!