Ми часто стикаємося із запитами на створення інтерактивних таблиць для B2B-додатків. Основна біль — продуктивність під час фільтрації, сортування та групування тисяч рядків. Розповімо, як ми вирішуємо це завдання за допомогою TanStack Table — headless-бібліотеки, яка дає повний контроль над UI та поведінкою. За 5 років досвіду ми реалізували понад 30 проєктів із таблицями даних, і TanStack Table — наш вибір за замовчуванням. Один із проєктів — CRM-система з таблицею замовлень на 50 000 рядків. Клієнтське сортування призводило до фризів на 10 секунд. Ми запропонували server-side обробку — проблема вирішилася.
Як реалізувати інтерактивні таблиці з фільтрацією, сортуванням та групуванням?
TanStack Table v8 — сучасне рішення для React. Встановлення: npm install @tanstack/react-table. Основні концепції: useReactTable приймає конфігурацію з моделями (getCoreRowModel, getSortedRowModel, getFilteredRowModel, getGroupedRowModel, getExpandedRowModel, getPaginationRowModel). Це дозволяє швидко додати функціональність без boilerplate. Для великих таблиць використовуємо react-virtual для віртуалізації рядків — це знижує навантаження на DOM.
Покрокова інструкція:
- Визначте інтерфейс даних, наприклад
Order. - Створіть колонки через
createColumnHelper. - Налаштуйте стан:
sorting,columnFilters,grouping,pagination. - Підключіть моделі рядків.
- Рендеріть таблицю: хедери з сортуванням, фільтри для колонок, тіло з групуванням та агрегацією, пагінацію.
Приклад: Таблиця замовлень з групуванням та фільтрацією — інтерактивні таблиці на
import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getGroupedRowModel, getExpandedRowModel, getPaginationRowModel, flexRender, createColumnHelper } from '@tanstack/react-table'; interface Order { id: string; customerName: string; status: OrderStatus; total: number; category: string; createdAt: Date; } const columnHelper = createColumnHelper<Order>(); const columns = [ columnHelper.accessor('id', { header: '№', size: 80, cell: ({ getValue }) => <code className="text-xs">{getValue().slice(0, 8)}</code> }), columnHelper.accessor('customerName', { header: 'Покупець', filterFn: 'includesString' }), columnHelper.accessor('status', { header: 'Статус', cell: ({ getValue }) => <StatusBadge status={getValue()} />, filterFn: (row, columnId, filterValue) => filterValue.includes(row.getValue(columnId)) }), columnHelper.accessor('total', { header: 'Сума', cell: ({ getValue }) => formatCurrency(getValue()), sortDescFirst: true, aggregationFn: 'sum', aggregatedCell: ({ getValue }) => ( <strong className="font-semibold">{formatCurrency(getValue<number>())}</strong> ) }), columnHelper.accessor('category', { header: 'Категорія' }), columnHelper.accessor('createdAt', { header: 'Дата', cell: ({ getValue }) => format(getValue(), 'dd.MM.yyyy HH:mm'), sortingFn: 'datetime' }) ]; function OrdersTable({ data }) { const [sorting, setSorting] = useState([{ id: 'createdAt', desc: true }]); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); const [grouping, setGrouping] = useState<string[]>([]); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); const table = useReactTable({ data, columns, state: { sorting, columnFilters, globalFilter, grouping, pagination }, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, onGroupingChange: setGrouping, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getGroupedRowModel: getGroupedRowModel(), getExpandedRowModel: getExpandedRowModel(), getPaginationRowModel: getPaginationRowModel() }); return ( <div> {/* Глобальний пошук */} <input value={globalFilter} onChange={e => setGlobalFilter(e.target.value)} placeholder="Пошук по всіх полях..." className="mb-4 px-3 py-2 border rounded-lg w-64" /> {/* Групування */} <div className="flex gap-2 mb-4"> <span className="text-sm text-gray-600">Групування:</span> {['status', 'category'].map(col => ( <button key={col} onClick={() => setGrouping(prev => prev.includes(col) ? prev.filter(c => c !== col) : [...prev, col] )} className={`text-xs px-2 py-1 rounded border ${ grouping.includes(col) ? 'bg-blue-50 border-blue-300' : 'border-gray-200' }`} > {col} </button> ))} </div> {/* Таблиця */} <div className="overflow-x-auto rounded-lg border border-gray-200"> <table className="w-full text-sm"> <thead className="bg-gray-50"> {table.getHeaderGroups().map(headerGroup => ( <tr key={headerGroup.id}> {headerGroup.headers.map(header => ( <th key={header.id} className="px-4 py-3 text-left font-medium text-gray-600 whitespace-nowrap" style={{ width: header.getSize() }} > <div className="flex items-center gap-1"> <span className={header.column.getCanSort() ? 'cursor-pointer select-none' : ''} onClick={header.column.getToggleSortingHandler()} > {flexRender(header.column.columnDef.header, header.getContext())} </span> {header.column.getIsSorted() === 'asc' && ' ↑'} {header.column.getIsSorted() === 'desc' && ' ↓'} </div> {/* Фільтр для кожної колонки */} {header.column.getCanFilter() && ( <ColumnFilter column={header.column} /> )} </th> ))} </tr> ))} </thead> <tbody className="divide-y divide-gray-100"> {table.getRowModel().rows.map(row => ( <tr key={row.id} className={`hover:bg-gray-50 ${row.getIsGrouped() ? 'bg-gray-50 font-medium' : ''}`}> {row.getVisibleCells().map(cell => ( <td key={cell.id} className="px-4 py-3"> {cell.getIsGrouped() ? ( <button onClick={row.getToggleExpandedHandler()} className="flex items-center gap-1"> {row.getIsExpanded() ? '▼' : '▶'} {flexRender(cell.column.columnDef.cell, cell.getContext())} ({row.subRows.length}) </button> ) : cell.getIsAggregated() ? ( flexRender(cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell, cell.getContext()) ) : flexRender(cell.column.columnDef.cell, cell.getContext())} </td> ))} </tr> ))} </tbody> </table> </div> {/* Пагінація */} <TablePagination table={table} /> </div> ); } Чому варто обрати server-side обробку для великих таблиць?
При роботі з більш ніж 10 000 рядків клієнтська обробка стає повільною — LCP зростає, користувачі відчувають фризи. Server-side сортування та фільтрація вирішують проблему: дані завантажуються порціями, а сортування виконується на сервері. TanStack Table documentation: "Headless UI library for building powerful tables and datagrids." Приклад реалізації:
function ServerSideTable() { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); const { data, isLoading } = useQuery({ queryKey: ['orders', { sorting, columnFilters, pagination }], queryFn: () => fetch('/api/orders?' + new URLSearchParams({ sort: sorting.map(s => `${s.id}:${s.desc ? 'desc' : 'asc'}`).join(','), filters: JSON.stringify(columnFilters), page: String(pagination.pageIndex + 1), per_page: String(pagination.pageSize) })).then(r => r.json()) }); const table = useReactTable({ data: data?.items ?? [], rowCount: data?.total ?? 0, manualSorting: true, manualFiltering: true, manualPagination: true, // ... }); } Порівняння підходів: client-side vs server-side
| Критерій | Client-side | Server-side |
|---|---|---|
| Кількість рядків | до 10 000 | від 10 000 |
| Швидкість сортування | миттєво | з затримкою мережі |
| Фільтрація | всі рядки | запит до API |
| Групування | на клієнті | на сервері |
| Навантаження на браузер | високе | низьке |
Для таблиць з об'ємом даних до 1 000 рядків client-side зазвичай швидше, оскільки не потребує мережевих запитів. При зростанні даних server-side стає єдино можливим варіантом — він у 5-10 разів знижує час відгуку інтерфейсу.
Детальний аналіз продуктивності: при 100 000 рядках client-side рендеринг може займати понад 5 секунд, тоді як server-side з пагінацією по 50 рядків завантажується за 0.2 сек. TanStack Table з virtualized rows (через react-virtual) дозволяє рендерити лише видимі рядки, що ще більше пришвидшує інтерфейс. На відміну від комерційних рішень, TanStack Table — безкоштовна open-source бібліотека, що суттєво економить бюджет проєкту.
Приклад конфігурації агрегації для групування
Для коректного відображення сум у згрупованих рядках потрібно задати aggregationFn для колонки. Наприклад, для колонки total використовуємо aggregationFn: 'sum'. Можна також визначити кастомну функцію.
Які переваги дає групування рядків в інтерактивній таблиці?
Групування дозволяє об'єднувати рядки за значенням колонки, наприклад, за статусом або категорією. Це спрощує аналіз даних: одразу видно кількість записів у кожній групі та агреговані значення (сума, середнє тощо). У TanStack Table групування реалізується через getGroupedRowModel та getExpandedRowModel, а користувач може згортати та розгортати групи. Групування ефективне при роботі з ієрархічними даними та часто використовується в системах аналітики.
Типові помилки при розробці таблиць з фільтрацією та сортуванням
- Ігнорування віртуалізації: без react-virtual навіть 5 000 рядків можуть викликати затримки. Завжди підключайте
@tanstack/react-virtual. - Відсутність debounce при фільтрації: кожне введення символу викликає перерисовку. Використовуйте
useDebounceіз затримкою 300 мс. - Неправильне налаштування колонок:
sizeтаminSizeповинні бути задані, інакше таблиця може роз'їжджатися. - Забута агрегація при групуванні: якщо не задати
aggregationFn, згруповані рядки покажутьundefined.
Що входить у розробку інтерактивної таблиці під ключ?
- Аналітика: визначення набору колонок, типів даних, вимог до продуктивності.
- Проектування: вибір архітектури (client-side/server-side), UI-компонентів.
- Реалізація: налаштування сортування, фільтрації, групування, пагінації, експорту, кастомних фільтрів (date range, multiselect).
- Тестування: перевірка на великих об'ємах даних, Edge-кейси, оптимізація Core Web Vitals.
- Деплой та документація: розміщення на сервері, опис API та інструкція для розробників.
- Навчання: передача знань команді замовника.
Ми гарантуємо, що таблиця працюватиме з Core Web Vitals у нормі (LCP < 2.5s, CLS < 0.1). Отримайте консультацію щодо вашого проєкту — зв'яжіться з нами.
Типові терміни та етапи
| Етап | Тривалість |
|---|---|
| Аналітика | 1-2 дні |
| Проектування та прототип | 2-3 дні |
| Реалізація core-функцій | 5-7 днів |
| Інтеграція з API (server-side) | 3-5 днів |
| Тестування та оптимізація | 2-3 дні |
Терміни розробки
- Базова таблиця (сортування, фільтри, пагінація, групування): від 5 до 7 днів.
- Додавання server-side, експорту в Excel/CSV, кастомних віджетів: ще від 5 до 7 днів.
Вартість розраховується індивідуально, залежить від складності та об'єму даних. Звертайтеся — підберемо оптимальне рішення.







