Data Table Patterns
Advanced data table patterns with TanStack Table for complex data management scenarios.
Overview#
Data tables extend basic tables with features needed for admin panels and data-heavy applications:
- Server-side data fetching with React Query
- Virtual scrolling for large datasets
- Column visibility and resizing
- Export functionality
- Inline editing
Prerequisites#
npm install @tanstack/react-table @tanstack/react-query lucide-reactServer-Side Table#
Fetch and display data from an API with pagination, sorting, and filtering.
1// components/tables/ServerTable.tsx
2'use client'
3
4import { useQuery } from '@tanstack/react-query'
5import { useState } from 'react'
6import {
7 useReactTable,
8 getCoreRowModel,
9 flexRender,
10 ColumnDef
11} from '@tanstack/react-table'
12
13interface TableParams {
14 page: number
15 pageSize: number
16 sortBy?: string
17 sortOrder?: 'asc' | 'desc'
18 filter?: string
19}
20
21interface TableResponse<T> {
22 data: T[]
23 total: number
24 page: number
25 pageSize: number
26}
27
28async function fetchTableData<T>(
29 endpoint: string,
30 params: TableParams
31): Promise<TableResponse<T>> {
32 const searchParams = new URLSearchParams({
33 page: String(params.page),
34 pageSize: String(params.pageSize),
35 ...(params.sortBy && { sortBy: params.sortBy }),
36 ...(params.sortOrder && { sortOrder: params.sortOrder }),
37 ...(params.filter && { filter: params.filter })
38 })
39
40 const response = await fetch(`${endpoint}?${searchParams}`)
41 return response.json()
42}
43
44export function ServerTable<T>({
45 endpoint,
46 columns
47}: {
48 endpoint: string
49 columns: ColumnDef<T>[]
50}) {
51 const [params, setParams] = useState<TableParams>({
52 page: 1,
53 pageSize: 10
54 })
55
56 const { data, isLoading, error } = useQuery({
57 queryKey: ['table-data', endpoint, params],
58 queryFn: () => fetchTableData<T>(endpoint, params)
59 })
60
61 const table = useReactTable({
62 data: data?.data ?? [],
63 columns,
64 getCoreRowModel: getCoreRowModel(),
65 manualPagination: true,
66 manualSorting: true,
67 pageCount: data ? Math.ceil(data.total / data.pageSize) : -1
68 })
69
70 if (isLoading) return <TableSkeleton />
71 if (error) return <div>Error loading data</div>
72
73 return (
74 <div>
75 {/* Search filter */}
76 <input
77 type="text"
78 placeholder="Search..."
79 value={params.filter ?? ''}
80 onChange={e => setParams(p => ({ ...p, filter: e.target.value, page: 1 }))}
81 className="mb-4 w-full max-w-sm rounded border px-3 py-2"
82 />
83
84 <table className="w-full">
85 <thead>
86 {table.getHeaderGroups().map(headerGroup => (
87 <tr key={headerGroup.id} className="border-b">
88 {headerGroup.headers.map(header => (
89 <th
90 key={header.id}
91 className="cursor-pointer p-3 text-left font-medium"
92 onClick={() => {
93 const column = header.column.id
94 setParams(p => ({
95 ...p,
96 sortBy: column,
97 sortOrder: p.sortBy === column && p.sortOrder === 'asc' ? 'desc' : 'asc'
98 }))
99 }}
100 >
101 {flexRender(header.column.columnDef.header, header.getContext())}
102 </th>
103 ))}
104 </tr>
105 ))}
106 </thead>
107 <tbody>
108 {table.getRowModel().rows.map(row => (
109 <tr key={row.id} className="border-b hover:bg-gray-50">
110 {row.getVisibleCells().map(cell => (
111 <td key={cell.id} className="p-3">
112 {flexRender(cell.column.columnDef.cell, cell.getContext())}
113 </td>
114 ))}
115 </tr>
116 ))}
117 </tbody>
118 </table>
119
120 {/* Pagination */}
121 <div className="mt-4 flex items-center justify-between">
122 <span className="text-sm text-gray-600">
123 Showing {((params.page - 1) * params.pageSize) + 1} to{' '}
124 {Math.min(params.page * params.pageSize, data?.total ?? 0)} of{' '}
125 {data?.total ?? 0} results
126 </span>
127
128 <div className="flex gap-2">
129 <button
130 onClick={() => setParams(p => ({ ...p, page: p.page - 1 }))}
131 disabled={params.page === 1}
132 className="rounded border px-3 py-1 disabled:opacity-50"
133 >
134 Previous
135 </button>
136 <button
137 onClick={() => setParams(p => ({ ...p, page: p.page + 1 }))}
138 disabled={params.page >= (data ? Math.ceil(data.total / data.pageSize) : 1)}
139 className="rounded border px-3 py-1 disabled:opacity-50"
140 >
141 Next
142 </button>
143 </div>
144 </div>
145 </div>
146 )
147}Column Visibility#
Allow users to show/hide columns.
1// components/tables/ColumnVisibilityTable.tsx
2'use client'
3
4import { useState } from 'react'
5import {
6 useReactTable,
7 getCoreRowModel,
8 VisibilityState,
9 flexRender
10} from '@tanstack/react-table'
11import { Settings } from 'lucide-react'
12
13export function ColumnVisibilityTable<T>({ data, columns }: Props<T>) {
14 const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
15 const [showColumnMenu, setShowColumnMenu] = useState(false)
16
17 const table = useReactTable({
18 data,
19 columns,
20 state: { columnVisibility },
21 onColumnVisibilityChange: setColumnVisibility,
22 getCoreRowModel: getCoreRowModel()
23 })
24
25 return (
26 <div>
27 {/* Column visibility toggle */}
28 <div className="relative mb-4">
29 <button
30 onClick={() => setShowColumnMenu(!showColumnMenu)}
31 className="flex items-center gap-2 rounded border px-3 py-2"
32 >
33 <Settings className="h-4 w-4" />
34 Columns
35 </button>
36
37 {showColumnMenu && (
38 <div className="absolute right-0 top-full z-10 mt-1 rounded border bg-white p-2 shadow-lg">
39 {table.getAllColumns().map(column => (
40 <label key={column.id} className="flex items-center gap-2 p-1">
41 <input
42 type="checkbox"
43 checked={column.getIsVisible()}
44 onChange={column.getToggleVisibilityHandler()}
45 />
46 {column.id}
47 </label>
48 ))}
49 </div>
50 )}
51 </div>
52
53 <table className="w-full">
54 {/* Only visible columns are rendered */}
55 </table>
56 </div>
57 )
58}Inline Editing#
Enable editing cells directly in the table.
1// components/tables/EditableTable.tsx
2'use client'
3
4import { useState } from 'react'
5import { useReactTable, getCoreRowModel, ColumnDef, flexRender } from '@tanstack/react-table'
6
7interface EditableCellProps<T> {
8 value: string
9 row: T
10 column: string
11 onSave: (row: T, column: string, value: string) => Promise<void>
12}
13
14function EditableCell<T>({ value: initialValue, row, column, onSave }: EditableCellProps<T>) {
15 const [value, setValue] = useState(initialValue)
16 const [editing, setEditing] = useState(false)
17
18 const handleSave = async () => {
19 await onSave(row, column, value)
20 setEditing(false)
21 }
22
23 if (editing) {
24 return (
25 <input
26 value={value}
27 onChange={e => setValue(e.target.value)}
28 onBlur={handleSave}
29 onKeyDown={e => e.key === 'Enter' && handleSave()}
30 autoFocus
31 className="w-full rounded border px-2 py-1"
32 />
33 )
34 }
35
36 return (
37 <span
38 onClick={() => setEditing(true)}
39 className="cursor-pointer rounded px-2 py-1 hover:bg-gray-100"
40 >
41 {value}
42 </span>
43 )
44}
45
46export function EditableTable<T extends { id: string }>({
47 data,
48 columns,
49 onUpdate
50}: {
51 data: T[]
52 columns: ColumnDef<T>[]
53 onUpdate: (id: string, field: string, value: string) => Promise<void>
54}) {
55 const editableColumns = columns.map(col => ({
56 ...col,
57 cell: ({ row, column, getValue }: any) => (
58 <EditableCell
59 value={getValue() as string}
60 row={row.original}
61 column={column.id}
62 onSave={async (row, col, value) => {
63 await onUpdate(row.id, col, value)
64 }}
65 />
66 )
67 }))
68
69 const table = useReactTable({
70 data,
71 columns: editableColumns,
72 getCoreRowModel: getCoreRowModel()
73 })
74
75 return (
76 <table className="w-full">
77 {/* ... table rendering */}
78 </table>
79 )
80}Export to CSV#
Add export functionality to download table data.
1// lib/table-export.ts
2export function exportToCSV<T extends Record<string, any>>(
3 data: T[],
4 columns: { key: keyof T; header: string }[],
5 filename: string
6) {
7 const headers = columns.map(c => c.header).join(',')
8 const rows = data.map(row =>
9 columns.map(c => {
10 const value = row[c.key]
11 // Escape commas and quotes
12 if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
13 return `"${value.replace(/"/g, '""')}"`
14 }
15 return value
16 }).join(',')
17 ).join('\n')
18
19 const csv = `${headers}\n${rows}`
20 const blob = new Blob([csv], { type: 'text/csv' })
21 const url = URL.createObjectURL(blob)
22
23 const link = document.createElement('a')
24 link.href = url
25 link.download = `${filename}.csv`
26 link.click()
27
28 URL.revokeObjectURL(url)
29}
30
31// Usage in component
32<button onClick={() => exportToCSV(data, [
33 { key: 'name', header: 'Name' },
34 { key: 'email', header: 'Email' },
35 { key: 'role', header: 'Role' }
36], 'users')}>
37 Export CSV
38</button>Best Practices#
- Debounce search inputs - Prevent excessive API calls while typing
- Cache table state - Use React Query for automatic caching and refetching
- Persist preferences - Save column visibility and sorting to localStorage
- Optimize re-renders - Memoize expensive cell renderers
- Add keyboard navigation - Support arrow keys and Enter for accessibility
Related Patterns#
- Tables - Basic table patterns
- React Query - Server state management
- URL State - Persist filters in URL