Реализация интерактивных таблиц (DataTables/TanStack Table) на сайте
Таблица с сортировкой и пагинацией — одна из самых частых задач во фронтенде, и одна из самых часто реализуемых плохо. jQuery DataTables всё ещё встречается в легаси-проектах, но в современных React-приложениях стандартом де-факто стал TanStack Table (ранее react-table) — headless-библиотека без встроенных стилей, которая даёт полный контроль над разметкой.
Выбор библиотеки
TanStack Table v8 — для React/Vue/Solid/Svelte проектов. Headless: никаких стилей в комплекте, только логика. Размер ~14 КБ gzipped.
jQuery DataTables — оправдан только если сайт уже использует jQuery и нет смысла добавлять React ради одной таблицы. В остальных случаях — не стоит.
AG Grid Community — когда нужна виртуализация на 100 000+ строк, Excel-экспорт и редактирование ячеек. Тяжелее, но мощнее.
TanStack Table: базовая реализация
npm install @tanstack/react-table
Определяем колонки и подключаем хук:
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
getFilteredRowModel,
useReactTable,
SortingState,
} from '@tanstack/react-table'
type Order = {
id: string
customer: string
amount: number
status: 'pending' | 'paid' | 'cancelled'
createdAt: string
}
const columnHelper = createColumnHelper<Order>()
const columns = [
columnHelper.accessor('id', {
header: '№ заказа',
cell: (info) => <span className="font-mono text-sm">{info.getValue()}</span>,
}),
columnHelper.accessor('customer', {
header: 'Клиент',
enableSorting: true,
}),
columnHelper.accessor('amount', {
header: 'Сумма',
cell: (info) => `${info.getValue().toLocaleString('ru-RU')} ₽`,
sortingFn: 'basic',
}),
columnHelper.accessor('status', {
header: 'Статус',
cell: (info) => <StatusBadge status={info.getValue()} />,
enableSorting: false,
}),
columnHelper.accessor('createdAt', {
header: 'Дата',
sortingFn: 'datetime',
}),
]
function OrdersTable({ data }: { data: Order[] }) {
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
initialState: { pagination: { pageSize: 25 } },
})
return (
<div>
<input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Поиск по всем полям..."
className="mb-4 w-64 border rounded px-3 py-2"
/>
<table className="w-full border-collapse">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className={header.column.getCanSort() ? 'cursor-pointer select-none' : ''}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="flex items-center gap-2 mt-4">
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
←
</button>
<span>
Страница {table.getState().pagination.pageIndex + 1} из {table.getPageCount()}
</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
→
</button>
<select
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 25, 50, 100].map((size) => (
<option key={size} value={size}>по {size}</option>
))}
</select>
</div>
</div>
)
}
Серверная пагинация
Для больших датасетов (10 000+ строк) пагинация на клиенте не работает — всё должно идти через API:
const [{ pageIndex, pageSize }, setPagination] = useState({
pageIndex: 0,
pageSize: 25,
})
// React Query для загрузки данных
const { data, isFetching } = useQuery({
queryKey: ['orders', pageIndex, pageSize, sorting, globalFilter],
queryFn: () =>
fetchOrders({
page: pageIndex + 1,
limit: pageSize,
sortBy: sorting[0]?.id,
sortDir: sorting[0]?.desc ? 'desc' : 'asc',
search: globalFilter,
}),
keepPreviousData: true, // не мигает при переходе между страницами
})
const table = useReactTable({
data: data?.rows ?? [],
columns,
pageCount: data?.pageCount ?? -1,
state: { sorting, pagination: { pageIndex, pageSize }, globalFilter },
manualPagination: true, // ключевой флаг
manualSorting: true,
manualFiltering: true,
onPaginationChange: setPagination,
// ...
})
Экспорт в CSV
function exportToCSV(table: Table<Order>) {
const headers = table.getAllColumns()
.filter((col) => col.getIsVisible())
.map((col) => col.columnDef.header as string)
const rows = table.getFilteredRowModel().rows.map((row) =>
row.getVisibleCells().map((cell) => {
const value = cell.getValue()
return typeof value === 'string' && value.includes(',') ? `"${value}"` : value
})
)
const csv = [headers, ...rows].map((r) => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `orders-${Date.now()}.csv`
a.click()
URL.revokeObjectURL(url)
}
\uFEFF — BOM для корректного отображения кириллицы в Excel.
Виртуализация строк
Для таблиц с тысячами строк на клиенте — TanStack Virtual:
npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'
const tableContainerRef = useRef<HTMLDivElement>(null)
const { rows } = table.getRowModel()
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 48, // высота строки в px
overscan: 10,
})
// В JSX:
<div ref={tableContainerRef} style={{ height: '600px', overflow: 'auto' }}>
<table>
<tbody style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<tr
key={row.id}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
}}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
10 000 строк рендерятся за ~20 мс — в DOM присутствует только то, что видно в viewport.
Что делаем
Анализируем структуру данных и требования: объём, частота обновлений, нужен ли inline-edit, экспорт, фиксированные колонки. Под задачу выбираем клиентскую или серверную модель пагинации, настраиваем колонки, сортировку, фильтрацию. Стилизуем под дизайн-систему проекта — таблица выглядит как часть интерфейса, а не вставка из другого приложения.
Срок: базовая таблица с сортировкой и пагинацией — 1 день. С серверной пагинацией, фильтрами и экспортом — 2–3 дня.







