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:

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!