יישום טבלה אינטראקטיבית (TanStack Table) באתר
תארו לעצמכם: ממשק ניהול ההזמנות שלכם מתחיל לגמגם כבר ב-1000 שורות, ולקוחות מתלוננים על טעינה איטית של הדף. נתקלנו בזה עשרות פעמים — ואנחנו יודעים איך לתקן את זה. טבלה עם מיון ופיצ'ינג (pagination) היא אחת המשימות הנפוצות ביותר בפיתוח פרונטאנד, ואחת מהמשימות שמיושמות לרוב בצורה גרועה. jQuery DataTables נמצא במערכות legacy, אבל באפליקציות React מודרניות הסטנדרט דה פקטו הוא TanStack Table (לשעבר react-table). זוהי ספרייה ללא עיצוב מובנה (headless) ללא סגנונות מובנים, המעניקה שליטה מלאה על מבנה ה-HTML. גודל ~14 KB דחוס, אפילו קטן יותר עם Tree-shaking. מתוחזקת באופן פעיל, כוללת טיפוסי TypeScript, ותואמת ל-React Server Components ב-Next.js. אם אתם משתמשים ב-Vue, Solid או Svelte — קיימות גרסאות מותאמות.
בעיות שאנחנו פותרים
רוב הטבלאות המותאמות אישית סובלות מכמה טעויות אופייניות:
- חוסר בווירטואליזציה — עם 5000 שורות ה-DOM עמוס, ו-React קופא למשך 2–3 שניות.
- פיצ'ינג בצד הלקוח על מערכי נתונים גדולים — טעינת 50,000 שורות הורגת את רוחב הפס של הרשת ואת זיכרון הדפדפן.
- אין debounce על סינון — כל הקלדה שולחת בקשה, ומעמיסה על השרת.
TanStack Table מטפל בזה ברמת הספרייה: אתם בוחרים רק את התכונות שאתם צריכים ומגדירים את ההתנהגות. היא לא כופה מודל נתונים — אתם מחליטים איך לאחסן ולטעון שורות. החלפת jQuery DataTables ב-TanStack Table יכולה להפחית את זמן הפיתוח ב-30%, ולהוריד את תקציב הפרויקט.
השוואת ספריות לטבלאות
| ספרייה | גודל | גמישות | ביצועים | קהילה |
|---|---|---|---|---|
| TanStack Table v8 | ~14 KB דחוס | מלאה (headless) | גבוהים (ווירטואליזציה) | >30K כוכבי GitHub |
| jQuery DataTables | ~80 KB (עם jQuery) | נמוכה (כולל סגנונות) | בינוניים (ללא ווירטואליזציה) | Legacy |
| AG Grid Community | ~200 KB דחוס | גבוהה (תכונות רבות) | גבוהים מאוד | >10K כוכבי GitHub |
זוהי הבחירה האופטימלית עבור React/Vue/Solid/Svelte. AG Grid מוצדק רק כשצריך עריכה בסגנון Excel על 100,000+ שורות. jQuery DataTables — אך ורק לתמיכה במערכות legacy. הספרייה מהירה פי 10 מ-jQuery DataTables בטיפול ב-10,000 שורות בזכות ווירטואליזציה והיעדר פעולות DOM מיותרות.
למה TanStack Table?
היא headless: ללא סגנונות מובנים, רק לוגיקה. אתם מחליטים איך הטבלה נראית — היא משתלבת בצורה מושלמת במערכת העיצוב שלכם. גודל ~14 KB דחוס, אפילו קטן יותר עם Tree-shaking. גרסה 8 תומכת ב-TypeScript וב-Server Components — עובדת בצורה חלקה ב-Next.js. כל מה שצריך זה להעביר נתונים ועמודות, וה-hook useReactTable מנהל את מצב המיון, הסינון והפיצ'ינג.
יישום בסיסי ב-React
התקנה:
npm install @tanstack/react-table דוגמה לרכיב טבלה עם חיפוש, מיון ופיצ'ינג
import { useState } from 'react'
import { ColumnDef, 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
}
function OrdersTable({ data }: { data: Order[] }) {
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState('')
const columns: ColumnDef<Order>[] = [
{
accessorKey: 'id',
header: '№ заказа',
cell: info => <span className='font-mono text-sm'>{info.getValue()}</span>,
},
{
accessorKey: 'customer',
header: 'Клиент',
enableSorting: true
},
{
accessorKey: 'amount',
header: 'Сумма',
cell: info => `${info.getValue<number>().toLocaleString('ru-RU')} $`,
sortingFn: 'basic',
},
{
accessorKey: 'status',
header: 'Статус',
cell: info => <StatusBadge status={info.getValue()} />,
enableSorting: false
},
{
accessorKey: 'createdAt',
header: 'Дата',
sortingFn: 'datetime'
},
]
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>
)
} איך ליישם פיצ'ינג בצד השרת עם React Query?
עבור מערכי נתונים מעל 10,000 שורות, פיצ'ינג בצד הלקוח אינו מקובל — אי אפשר לטעון את כל הנתונים. השתמשו ב-npm install @tanstack/react-table , import { useState } from 'react' import { ColumnDef, 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 } function OrdersTable({ data }: { data: Order[] }) { const [sorting, setSorting] = useState<SortingState>([]) const [globalFilter, setGlobalFilter] = useState('') const columns: ColumnDef<Order>[] = [ { accessorKey: 'id', header: '№ заказа', cell: info => <span className='font-mono text-sm'>{info.getValue()}</span>, }, { accessorKey: 'customer', header: 'Клиент', enableSorting: true }, { accessorKey: 'amount', header: 'Сумма', cell: info => `${info.getValue<number>().toLocaleString('ru-RU')} $`, sortingFn: 'basic', }, { accessorKey: 'status', header: 'Статус', cell: info => <StatusBadge status={info.getValue()} />, enableSorting: false }, { accessorKey: 'createdAt', header: 'Дата', sortingFn: 'datetime' }, ] 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> ) } , manualPagination וטענו נתונים דרך React Query:
const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, pageSize: 25 })
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,
})המפתח הוא הדגלים manualSorting — הם משביתים את הטיפול המובנה, ומעבירים את השליטה לשרת. React Query עם manualFiltering מונע קפיצות בממשק במהלך הטעינה.
השוואת מודלי פיצ'ינג
| פרמטר | צד הלקוח | צד השרת |
|---|---|---|
| גודל נתונים | עד 10,000 שורות | כל כמות |
| זמן טעינה ראשוני | תלוי בגודל מערך הנתונים | מיידי (עמוד 1) |
| סינון | בצד הלקוח (אופליין) | בצד השרת (אינדקסי SQL) |
| עומס על השרת | טעינה אחת גדולה | בקשות קטנות רבות |
איך לייצא טבלה ל-CSV ול-Excel?
ייצוא נתונים מסוננים ל-CSV עם BOM עבור כתב קירילי:
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)
}במידת הצורך — ייצוא ל-XLSX באמצעות const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, pageSize: 25 }) 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, }) . אנו מגדירים פורמט תאים, מיזוג וסגנונות.
ווירטואליזציה עבור 100,000+ שורות
שימו לב: כשיש הרבה שורות אבל הפיצ'ינג הוא בצד הלקוח — ווירטואליזציה של שורות דרך manual* מציגה רק את השורות הנראות:
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,
overscan: 10,
})10,000 שורות נטענות בכ-20 אלפיות השנייה — רק אזור התצוגה נמצא ב-DOM.
מה כלול בעבודה
- ניתוח מבנה הנתונים וה-API, בחירת מודל פיצ'ינג.
- עיצוב הגדרת עמודות, מיון, מסננים.
- יישום באמצעות TanStack Table עם אינטגרציה למערכת העיצוב.
- הגדרת פיצ'ינג בצד השרת, ייצוא, ווירטואליזציה.
- תיעוד שימוש ותמיכה.
לוחות זמנים: טבלה בסיסית עם מיון ופיצ'ינג — יום אחד. עם פיצ'ינג בצד השרת, מסננים וייצוא — 2–3 ימים. ווירטואליזציה מוסיפה 1–2 ימים. אנו מעריכים את הפרויקט — פשוט כתבו לנו. הזמינו פיתוח טבלה סוהר — קבלו פתרון מוכן תוך יום אחד. קבלו ייעוץ על אינטגרציה של TanStack Table בפרויקט שלכם.
אנחנו עוסקים בפיתוח פרונטאנד כבר 5+ שנים, והשלמנו 50+ פרויקטים עם טבלאות. אנו משתמשים בטכנולוגיות מודרניות: React, TypeScript, TanStack Table, React Query. התוצאה — טבלאות מהירות וגמישות שלא גמגמות על נתונים גדולים. חיסכון ברישוי בהשוואה ל-AG Grid יכול להגיע ל-40% מהתקציב.
אחריות — אנו מתקנים באגים בחינם תוך 30 יום מהמסירה. צרו קשר — נעריך את הפרויקט שלכם תוך יום אחד.







