Создание таблицы данных с TanStack Table
Создайте data grid на TanStack Table с сортировкой, фильтрацией, пагинацией и виртуализацией, а также исправьте баги мемоизации и бесконечные рендеры.
TanStack Table — это headless-библиотека: она берёт на себя логику таблицы (сортировку, фильтрацию, пагинацию и row models), оставляя вам 100% контроля над разметкой и стилями.
Если вы хоть раз провели полдня в борьбе со стилями готового грида ради смещения одной границы на пару пикселей, такой подход покажется вам очевидным. Вы получаете проверенный в бою движок состояний и чистый холст для DOM — именно то, что нужно, когда дизайн-система ваша.
В этом руководстве мы собираем реальную таблицу данных на актуальной стабильной версии @tanstack/react-table v8: единая таблица с сортировкой, глобальной фильтрацией и пагинацией. Затем разберём две проблемы масштабирования, которые реально дают о себе знать в продакшне: баг с мемоизацией, вызывающий бесконечные перерендеры, и виртуализация для тысяч строк.
Ключевые выводы
- В TanStack Table v8 каждый заголовок и ячейку нужно рендерить через
flexRender:flexRender(header.column.columnDef.header, header.getContext())для заголовков иflexRender(cell.column.columnDef.cell, cell.getContext())для ячеек. Вызовыcell.render('Cell')иcolumn.render('Header')из эпохи v7 больше не существуют. - Сортировка, фильтрация и пагинация — это подключаемые row models: передайте
getSortedRowModel(),getFilteredRowModel()иgetPaginationRowModel()в один вызовuseReactTable, и порядок их указания не имеет значения. - Самый распространённый баг в TanStack Table — бесконечный цикл перерендеров из-за передачи нового массива
dataилиcolumnsпри каждом рендере. Оберните оба вuseMemoили определите их на уровне модуля. - При нескольких тысячах строк на клиенте откажитесь от пагинации и используйте виртуализацию через
useVirtualizerиз@tanstack/react-virtualповерхtable.getRowModel().rows. - Стабильная версия — v8, на данный момент 8.21.3 под тегом
latestна npm. В июне 2026 года v9 перешла в бета-стадию, поэтому продакшн-таблицы держите на v8.
Что означает «headless» и почему это важно
Headless означает, что библиотека поставляет поведение, а не UI. TanStack Table вычисляет row models и предоставляет состояние и обработчики, но ничего не помещает в DOM: в v8 были убраны стили по умолчанию и атрибуты role, чтобы ядро оставалось независимым от фреймворка. Плюс — полный контроль. Ответственность — семантика тоже на вас: <table>, <th scope> и любые ARIA-роли — ваша забота.
На практике это означает, что стилизация полностью в ваших руках: Tailwind utility-классы, CSS Modules или styled components на вашей собственной разметке <table>. Библиотека к ней никогда не прикасается.
Базовая настройка таблицы с TanStack Table
Discover how at OpenReplay.com.
Установите пакет и создайте таблицу с помощью хука useReactTable. Определите колонки через createColumnHelper для вывода типов, передайте data и columns, и зарегистрируйте getCoreRowModel() — базовую row model, которая преобразует ваши данные в строки.
npm install @tanstack/react-table
Адаптер поддерживает все версии React с 16.8 по 19, с оговоркой, что он может вести себя непредсказуемо под React Compiler, поставляемым вместе с React 19.
import {
createColumnHelper,
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table'
type User = { firstName: string; lastName: string; age: number }
const columnHelper = createColumnHelper<User>()
const columns = [
columnHelper.accessor('firstName', { header: 'First Name' }),
columnHelper.accessor('lastName', { header: 'Last Name' }),
columnHelper.accessor('age', { header: 'Age' }),
]
Рендеринг осуществляется путём обхода getHeaderGroups(), getRowModel().rows и getVisibleCells() каждой строки. Передавайте определение колонки и её контекст в flexRender, чтобы строковые заголовки, JSX и компонентные ячейки корректно разрешались. Согласно руководству по миграции v8, flexRender заменяет удалённые вызовы cell.render('Cell') / column.render('Header').
function DataGrid({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<table>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(header => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
Добавление сортировки
Включите сортировку, добавив getSortedRowModel(), сохраняя состояние sorting через onSortingChange и привязав каждый заголовок к header.column.getToggleSortingHandler(). Используйте header.column.getIsSorted() (возвращает 'asc', 'desc' или false) для отображения индикатора направления.
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
<th onClick={header.column.getToggleSortingHandler()} style={{ cursor: 'pointer' }}>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}
</th>
Фильтрация и глобальный поиск
Глобальный поиск — это одна единица состояния: добавьте getFilteredRowModel(), сохраняйте строку globalFilter в state и обновляйте её через onGlobalFilterChange. Стандартный глобальный фильтр ищет подстроки по всем колонкам.
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
// <input value={globalFilter} onChange={e => setGlobalFilter(e.target.value)} />
Фильтры по отдельным колонкам используют ту же row model и управляются через column.getFilterValue() и column.setFilterValue() из поля ввода в заголовке. Нечёткое совпадение доступно, но требует отдельного хелпера @tanstack/match-sorter-utils в качестве кастомного filterFn. Прибегайте к нему только тогда, когда поиска по подстроке недостаточно.
Пагинация
Добавьте getPaginationRowModel() и управляйте им через состояние pageIndex/pageSize, затем блокируйте кнопки «Следующая»/«Предыдущая» с помощью table.getCanNextPage() и table.getCanPreviousPage(). Порядок регистрации row models в v8 не имеет значения, поэтому они свободно компонуются вместе с сортировкой и фильтрацией в одном вызове хука.
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter, pagination },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
Previous
</button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</button>
Это полноценная рабочая таблица: один вызов useReactTable, объединяющий сортировку, фильтрацию и пагинацию.
Масштабирование: проблема мемоизации, виртуализация и кастомные ячейки
Самый распространённый баг в TanStack Table — бесконечный цикл перерендеров, вызванный передачей нового массива data или columns при каждом рендере. Объявление const columns = [...] или const data = [...] внутри тела компонента создаёт новую ссылку при каждом проходе; TanStack Table фиксирует изменение идентичности, пересчитывает row models, инициирует перерендер — и цикл повторяется. Оберните оба в useMemo (или определите их на уровне модуля, как в приведённых выше примерах), чтобы их ссылки оставались стабильными.
const columns = useMemo(() => [/* ... */], [])
const data = useMemo(() => fetchedRows, [fetchedRows])
Пагинация или виртуализация? Используйте пагинацию, когда размеры страниц ограничены или данные подгружаются постранично с сервера. Используйте виртуализацию, когда вы держите тысячи строк на клиенте в одном непрерывном скролл-представлении. В этом случае рендерите только видимые строки с помощью @tanstack/react-virtual и его хука useVirtualizer поверх table.getRowModel().rows. Обратите внимание: в актуальной версии v3 хук называется useVirtualizer, а не устаревший useVirtual.
import { useVirtualizer } from '@tanstack/react-virtual'
const rows = table.getRowModel().rows
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 10,
})
Затем вы рендерите rowVirtualizer.getVirtualItems() внутри контейнера-спейсера, размер которого задаётся через getTotalSize(), как показано в документации TanStack Virtual. Это именно тот сценарий отказа, который хорошо выявляет session replay: невиртуализированная таблица из 10 тысяч строк нормально работает на тестовых данных локально, но воспроизведение реального пользователя, сортирующего или быстро прокручивающего её в продакшне, обнажает просадки кадров и рывки при скролле.
Кастомные ячейки — это просто рендерер cell колонки: возвращайте статусный бейдж, отформатированную дату или редактируемый input, и библиотека не будет вам мешать, поскольку сама никогда не рендерит UI. Закрепление колонок, изменение их размера и переупорядочивание также поддерживаются через собственные state API.
Итоги
Теперь у вас есть таблица с сортировкой, фильтрацией и пагинацией в одном вызове useReactTable, а также два приёма, обеспечивающих её производительность: стабильные ссылки на data/columns и виртуализация при нескольких тысячах строк. Разрабатывайте на v8, поскольку v9 находится в бете с июня 2026 года, и переходите на useVirtualizer в тот момент, когда количество строк перестаёт умещаться на одной странице.
Часто задаваемые вопросы
Почему моя таблица TanStack Table перерендеривается бесконечно?
Бесконечный цикл перерендеров почти всегда вызван передачей нового массива data или columns при каждом рендере. Объявление const columns = [] или const data = [] внутри тела компонента создаёт новую ссылку при каждом проходе, поэтому TanStack Table фиксирует изменение идентичности, пересчитывает row models и инициирует очередной рендер. Оберните оба в useMemo или определите их на уровне модуля, чтобы их ссылки оставались стабильными.
Какую версию использовать: TanStack Table v8 или v9?
Используйте v8 для продакшна. Стабильная версия — v8 (8.21.3 под тегом latest на npm), тогда как v9 находится в бете с июня 2026 года и поставляется только как предрелизные сборки под тегом beta на npm. v8 работает с React 16.8, 17, 18 и 19. Держите продакшн-таблицы на v8 и экспериментируйте с v9 только в некритичном коде до выхода стабильного релиза.
Когда использовать пагинацию, а когда — виртуализацию в TanStack Table?
Используйте пагинацию, когда размеры страниц ограничены или данные подгружаются постранично с сервера — через getPaginationRowModel и состояние pageIndex/pageSize. Используйте виртуализацию, когда вы держите тысячи строк на клиенте в одном непрерывном скролл-представлении: рендерите только видимые строки с помощью useVirtualizer из @tanstack/react-virtual поверх table.getRowModel().rows. Эти подходы не исключают друг друга, однако при нескольких тысячах клиентских строк в одной скролл-области виртуализация — правильный выбор.
Предоставляет ли TanStack Table стили по умолчанию или ARIA-роли?
Нет. TanStack Table v8 полностью headless. Он не создаёт разметку, стили и атрибуты role; переработка v8 убрала всё это, чтобы ядро работало с любым фреймворком. Весь DOM — на вашей ответственности, поэтому семантическая разметка — table, th с атрибутом scope и любые ARIA-роли — ваша задача. Стилизуйте с помощью Tailwind, CSS Modules или styled components на собственной разметке.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k