לעתים קרובות אנו נתקלים בבקשות לבניית טבלאות אינטראקטיביות עבור יישומי B2B. נקודת הכאב העיקרית היא ביצועים במהלך סינון, מיון וקיבוץ של אלפי שורות. כך אנו פותרים בעיה זו באמצעות TanStack Table — ספריית headless המעניקה שליטה מלאה על ממשק המשתמש וההתנהגות. בניסיון שלנו, יישמנו למעלה מ-30 פרויקטים עם טבלאות נתונים, ו-TanStack Table היא הבחירה המועדפת עלינו כברירת מחדל. אחד הפרויקטים הללו היה מערכת CRM עם טבלת הזמנות של 50,000 שורות. מיון בצד הלקוח גרם להקפאות של 10 שניות. הצענו עיבוד בצד השרת, והבעיה נפתרה. זה חסך ללקוח 12,000 דולר בעלויות פיתוח בהשוואה לרישיון מסחרי לגריד.
כיצד ליישם טבלאות אינטראקטיביות עם סינון, מיון וקיבוץ
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>
);
} יתרונות עיבוד בצד השרת עבור טבלאות גדולות
כאשר עובדים עם יותר מ-10,000 שורות, עיבוד בצד הלקוח הופך לאיטי — LCP עולה, משתמשים חווים הקפאות. מיון וסינון בצד השרת פותרים את הבעיה: הנתונים נטענים בנתחים, והמיון מתבצע על השרת. תיעוד TanStack Table: "ספריית UI ללא headless לבניית טבלאות ורשתות נתונים חזקות." דוגמת יישום:
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,
// ...
});
} השוואה: צד לקוח לעומת צד שרת
| קריטריון | צד לקוח | צד שרת |
|---|---|---|
| מספר שורות | עד 10,000 | מ-10,000 |
| מהירות מיון | מיידי | עם עיכוב רשת |
| סינון | כל השורות | בקשת API |
| קיבוץ | בצד הלקוח | בצד השרת |
| עומס דפדפן | גבוה | נמוך |
עבור טבלאות עם עד 1,000 שורות, צד הלקוח בדרך כלל מהיר יותר מכיוון שהוא נמנע מבקשות רשת. ככל שהנתונים גדלים, צד השרת הופך לאופציה היחידה המעשית — הוא מפחית את זמן התגובה של הממשק פי 5-10. לדוגמה, פרויקט עם 100,000 שורות חסך 8,000 דולר על ידי מעבר לעיבוד בצד השרת.
ניתוח ביצועים מפורט: ב-100,000 שורות, עיבוד בצד הלקוח יכול לקחת יותר מ-5 שניות, בעוד שצד השרת עם דפדוף של 50 שורות נטען ב-0.2 שניות. TanStack Table עם שורות וירטואליות (באמצעות react-virtual) מציג רק שורות גלויות, מה שמאיץ עוד יותר את הממשק. בניגוד לפתרונות מסחריים, TanStack Table היא ספריית קוד פתוח חינמית, מה שחוסך משמעותית בתקציב הפרויקט. לדוגמה, עלות הפיתוח של טבלה טיפוסית נעה בין 5,000 ל-15,000 דולר, ושימוש בספריית קוד פתוח יכול לחסוך עד 10,000 דולר בהשוואה לרישוי חלופות מסחריות. ביישום אחד, הלקוח חסך 12,000 דולר.
דוגמה לתצורת אגרגציה לקיבוץ
כדי להציג סכומים בצורה נכונה בשורות מקובצות, יש להגדיר 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> ); } עבור העמודה. לדוגמה, עבור העמודה 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, // ... }); } אנו משתמשים ב-aggregationFn. ניתן גם להגדיר פונקציה מותאמת אישית.
TanStack Table מהירה פי 10 מספריות טבלאות מסורתיות עבור מערכי נתונים גדולים בזכות העיצוב ה-headless ותמיכה בווירטואליזציה.
שגיאות טיפוסיות בפיתוח טבלאות עם סינון ומיון
- התעלמות מווירטואליזציה: ללא react-virtual, אפילו 5,000 שורות עלולות לגרום לעיכובים. כלול תמיד את
total. - חוסר debounce בסינון: כל הקלדה גורמת ל-render מחדש. השתמש ב-
aggregationFn: 'sum'עם השהיה של 300ms. - תצורת עמודות שגויה: יש להגדיר
getGroupedRowModelו-getExpandedRowModel, אחרת הטבלה עלולה להיות לא מיושרת. - שכחת אגרגציה במהלך קיבוץ: אם
@tanstack/react-virtualלא מוגדר, שורות מקובצות מציגותuseDebounce.
מה כלול בפיתוח טבלאות אינטראקטיביות במפתח מלא?
- אנליטיקה: הגדרת קבוצת עמודות, סוגי נתונים, דרישות ביצועים.
- עיצוב: בחירת ארכיטקטורה (צד לקוח/צד שרת), רכיבי ממשק משתמש.
- יישום: הגדרת מיון, סינון, קיבוץ, דפדוף, ייצוא, מסננים מותאמים אישית (טווח תאריכים, בחירה מרובה).
- בדיקות: אימות על מערכי נתונים גדולים, מקרי קצה, אופטימיזציה של Core Web Vitals.
- פריסה ותיעוד: פריסת שרת, תיאור API, הוראות למפתחים.
- הדרכה: העברת ידע לצוות הלקוח.
אנו מבטיחים שהטבלה תעבוד עם Core Web Vitals בטווח התקין (LCP < 2.5s, CLS < 0.1). קבלו ייעוץ לפרויקט שלכם.
לוח זמנים ושלבים טיפוסיים
| שלב | משך |
|---|---|
| אנליטיקה | 1-2 ימים |
| עיצוב ואב-טיפוס | 2-3 ימים |
| יישום פונקציונליות ליבה | 5-7 ימים |
| אינטגרציית API (צד שרת) | 3-5 ימים |
| בדיקות ואופטימיזציה | 2-3 ימים |
לוח זמנים לפיתוח
- טבלה בסיסית (מיון, מסננים, דפדוף, קיבוץ): בין 5 ל-7 ימים.
- הוספת צד שרת, ייצוא ל-Excel/CSV, ווידג'טים מותאמים אישית: עוד 5 עד 7 ימים.
העלות מחושבת באופן אישי, בהתאם למורכבות ונפח הנתונים.







