Solve Any Data Table Problem using TanStack Table

TanStack Table 拯救前端複雜的資料表格呈現問題

前言

前端生涯中遇到無數攸關資料呈現的需求需要解決,而 TanStack Table🔗 是一款第一眼看起來很多餘複雜但實際上能省下無數時間的一個優質套件,如果有任何「大量表單、中大型表單應用」我會強烈推薦。

在實際工作中我透過導入 TanStack Table 在幾天時間內就能把現有表單大多複雜功能重現 ,並開始大量製作可複用、擴充性高且簡潔的資料表單,因為它:

  • Headless UI 庫,意味著它不綁定任何樣式,只提供狀態與邏輯,可以與任何現有介面進行整合。
  • 提供各大主流框架(React、Vue、Angular)適配器。
  • 免費開源且熱門🔗,範例眾多且文件完善清楚,支援 TypeScript。

資料表單背後的複雜

顯示資料到表格上沒什麼困難的對吧?最簡單的表單就是拿到資料 → 渲染上畫面 → 保持資料更新,但實際上還有很多問題需要考慮,像是兼顧維護客戶端與伺服端狀態與邏輯(載入資料、分頁、搜尋、排序⋯⋯)就足夠頭痛。

由於沒有事先規劃,導致有一次一項微小的功能改動(分頁筆數數量調整)都要耗費 3 個工作天,對上百個資料表單進行散彈槍手術🔗式的修改,既沒效率又不可靠,當專案越做越大、功能越加越多,會發現自己正在重刻一套「小型的表格框架」,而且代碼相互耦合 Bug 不斷。

TanStack Table 核心概念

初次見到以下概念與 API 可能會覺得很複雜,因為它的定位偏向是一款需要自己配置的工具箱,接上自己的 UI 會需要付出額外的時間和經驗,初入門可以參考像是 Shadcn 有不錯的文件與逐步範例 TanStack Table 整合🔗

實際範例

以建立一個客戶端表單 Vue ClientDataTable.vue 為案例,最重要可以看到它接收 columns(列定義)與 data (表單資料),並且在內部定義完善資料狀態與更新流程。

<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="搜尋欄位⋯⋯"
filtering-column="email"
/>
</template>
</ClientSideDataTable>

客戶端表單實踐起來大致會像底下這樣,預留了一個 header slot 暴露選取狀態與 filter slot 暴露表單狀態:

<template>
<div>
<div v-if="$slots.header">
<!-- 功能選單 -->
<slot name="header" :row-selection="rowSelection" />
</div>
<div v-if="$slots.filter">
<!-- 過濾選單 -->
<slot name="filter" :table="table" />
</div>
<!-- 資料表渲染 -->
<DataTable :table="table" :columns="columns" :is-loading="isLoading" />
</div>
<!-- 資料表分頁 -->
<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>

更麻煩的伺服器表單問題在於狀態都來自表單外部,這時候如果透過先前提到的「如何透過 TanStack Query 建構更好的前端資料獲取體驗」整合 TanStack Query 把拿到的狀態與表單分頁、排序與搜索⋯⋯等計算邏輯封裝出一個 useServerSideDataTable Composable 如下:

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(() => {
// 分頁系統索引起點差異需要轉換
const zeroToOneBasedIndexOffset = 1
const firstSortingId = sorting.value[0]?.id
const firstSortDesc = sorting.value[0]?.desc
// TanStack Table column 欄位不一定是 string,可能是數字、日期、選單,但我們應該只會用到 string 搜尋,所以先斷言為 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 {
// 供 ServerSideDataTable 可用的表格狀態
pagination,
sorting,
columnFilters,
pageCount,
// TanStack Query 回應
...query,
}
}

就能把大多伺服器表單需要的邏輯包裝起來,灌入 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 = {
// 已經建構好的 TableFilter,組合最終請求
...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"/>

總結

對於 TanStack Table 這類大型成熟的套件 AI 有較多的知識儲備,很多問題其實詢問 AI 起個頭再翻下文件都會明朗很多。

我很滿意現在創建資料表單超輕鬆,且後續維護和擴充都非常方便!