טבלאות React אינטראקטיביות: סינון, מיון, קיבוץ

עבודה עם נפחי נתונים גדולים ביישומי B2B נתקלת לא פעם בטבלאות איטיות ומסורבלות, שבהן סינון ומיון הופכים להמתנה. אנו בונים טבלאות אינטראקטיביות ב-React באמצעות TanStack Table, ומספקים סינון, מיון וקיבוץ מוכנים לשימוש. הצוות שלנו מטפל בכל התהליך—מעיצוב ועד הטמעה ותמיכה—ומבטיח ביצועים יציבים גם עם אלפי שורות.

פיתוח ותחזוקה של כל סוגי האתרים:

אתרי מידע או יישומי אינטרנט
אתרי תדמית, דפי נחיתה, אתרי חברה, קטלוגים מקוונים, חידונים, אתרי קידום, בלוגים, מקורות חדשות, פורטלי מידע, פורומים, אגרגטורים
אתרי מסחר אלקטרוני או יישומי אינטרנט
חנויות מקוונות, פורטלי B2B, שווקים, בורסות מקוונות, אתרי קאשבק, בורסות, פלטפורמות דרופשיפינג, מנתחי מוצרים
יישומי אינטרנט לניהול תהליכים עסקיים
מערכות CRM, מערכות ERP, פורטלים ארגוניים, מערכות ניהול ייצור, מנתחי מידע
אתרי שירות אלקטרוני או יישומי אינטרנט
פלטפורמות מודעות, בתי ספר מקוונים, בתי קולנוע מקוונים, בוני אתרים, פורטלים לשירותים אלקטרוניים, פלטפורמות אירוח וידאו, פורטלים נושאיים

אלה רק חלק מהסוגים הטכניים של אתרים שאנו עובדים איתם, ולכל אחד מהם יכולים להיות מאפיינים ופונקציונליות ספציפיים משלו, וכן ניתן להתאים אותם לצרכים ולמטרות הספציפיים של הלקוח.

השירותים שאנו מציעים
מציג 1 מתוך 1כל 2062 השירותים
טבלאות React אינטראקטיביות: סינון, מיון, קיבוץ
בינוני
~3-5 ימים

הכישורים שלנו:

שאלות נפוצות

העבודות האחרונות

  • פיתוח אפליקציית ווב עבור FEEDME
    פיתוח אפליקציית ווב עבור FEEDME
    1365
  • פיתוח חנות מקוונת לחברת FURNORO
    פיתוח חנות מקוונת לחברת FURNORO
    1330
  • פיתוח אפליקציית ווב עבור Enviok
    פיתוח אפליקציית ווב עבור Enviok
    1077
  • פיתוח CRM עבור Chasseurs
    פיתוח CRM עבור Chasseurs
    1113
  • פיתוח אתר עבור SBH Partners
    פיתוח אתר עבור SBH Partners
    1201
  • פיתוח אתר עבור Red Pear
    פיתוח אתר עבור Red Pear
    619

לעתים קרובות אנו נתקלים בבקשות לבניית טבלאות אינטראקטיביות עבור יישומי 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.

מדריך שלב אחר שלב:

  1. הגדר ממשק נתונים, לדוגמה, Order.
  2. צור עמודות באמצעות createColumnHelper.
  3. הגדר מצב: sorting, columnFilters, grouping, pagination.
  4. חבר מודלי שורות.
  5. עבד את הטבלה: כותרות הניתנות למיון, מסנני עמודות, גוף עם קיבוץ ואגרגציה, דפדוף.

דוגמה: טבלת הזמנות עם קיבוץ וסינון

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 ימים.

העלות מחושבת באופן אישי, בהתאם למורכבות ונפח הנתונים.