Ми часто стикаємося із запитами на створення інтерактивних таблиць для 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 днів.
Вартість розраховується індивідуально, залежить від складності та об'єму даних. Звертайтеся — підберемо оптимальне рішення.







