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-react

Server-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#

  1. Debounce search inputs - Prevent excessive API calls while typing
  2. Cache table state - Use React Query for automatic caching and refetching
  3. Persist preferences - Save column visibility and sorting to localStorage
  4. Optimize re-renders - Memoize expensive cell renderers
  5. Add keyboard navigation - Support arrow keys and Enter for accessibility