TanStack Table هو المعيار الذهبي لجداول البيانات بدون واجهة مسبقة في React. يمنحك تحكماً كاملاً في البنية والتصميم مع إدارة جميع المنطق المعقد — الفرز والتصفية والترقيم والتصيير الافتراضي — مباشرةً من الصندوق. هذا الدرس يبني لوحة تحكم للفواتير متكاملة الميزات يمكنك تكييفها لأي مشروع حقيقي.
ما الذي ستبنيه
تطبيق لوحة تحكم الفواتير يتضمن:
- أعمدة قابلة للفرز (تصاعدي وتنازلي)
- تصفية على مستوى العمود والبحث العالمي
- ترقيم الصفحات متوافق مع الخادم
- تحديد متعدد للصفوف مع إجراءات جماعية
- تبديل رؤية الأعمدة
- تصدير إلى CSV
- أمان النوع الكامل مع TypeScript
- تنسيق باستخدام Tailwind CSS
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبت
- فهم جيد لـ React و TypeScript
- إلمام بـ Next.js App Router
- معرفة أساسية بـ Tailwind CSS
- محرر كود (يُنصح بـ VS Code)
لماذا TanStack Table؟
معظم مكتبات الجداول تأتي بمكونات صارمة تجبرك على نظامها البصري. TanStack Table يأخذ النهج المعاكس — هو أداة بلا رأس تدير الحالة والمنطق دون أن تُصيِّر شيئاً. أنت تكتب <table> و<thead> و<tbody> بنفسك، مما يعني:
- لا تعارضات في الأنماط مع نظام التصميم الخاص بك
- تحكم كامل في إمكانية الوصول
- حجم حزمة صغير (أقل من 15 كيلوبايت مضغوطاً)
- يعمل مع أي مكتبة واجهة مستخدم: Tailwind وshadcn/ui وRadix وMaterial UI
الخطوة 1: إعداد المشروع
أنشئ مشروع Next.js 15 جديداً:
npx create-next-app@latest invoice-dashboard --typescript --tailwind --eslint --app
cd invoice-dashboardثبِّت TanStack Table:
npm install @tanstack/react-tableهيكل الملفات الأولي:
invoice-dashboard/
├── app/
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ └── data-table/
├── lib/
│ └── data.ts
└── types/
└── invoice.ts
الخطوة 2: تعريف الأنواع والبيانات التجريبية
أنشئ نوع الفاتورة:
// types/invoice.ts
export type InvoiceStatus = "paid" | "pending" | "overdue";
export interface Invoice {
id: string;
invoiceNumber: string;
client: string;
email: string;
amount: number;
status: InvoiceStatus;
issueDate: string;
dueDate: string;
}أنشئ بيانات تجريبية لملء الجدول:
// lib/data.ts
import { Invoice } from "@/types/invoice";
export const invoices: Invoice[] = [
{
id: "1",
invoiceNumber: "INV-001",
client: "شركة النمو",
email: "billing@growth.tn",
amount: 4200,
status: "paid",
issueDate: "2026-01-10",
dueDate: "2026-02-10",
},
{
id: "2",
invoiceNumber: "INV-002",
client: "مؤسسة الرقي",
email: "accounts@alruqy.com",
amount: 8750,
status: "pending",
issueDate: "2026-01-15",
dueDate: "2026-02-15",
},
{
id: "3",
invoiceNumber: "INV-003",
client: "تقنية المستقبل",
email: "finance@techfuture.io",
amount: 1350,
status: "overdue",
issueDate: "2025-12-01",
dueDate: "2026-01-01",
},
];الخطوة 3: تعريف إعدادات الأعمدة
تعريفات الأعمدة هي قلب TanStack Table. كل عمود يصف كيفية الوصول إلى البيانات وكيفية تصييرها والميزات المطبقة:
// components/data-table/columns.tsx
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Invoice, InvoiceStatus } from "@/types/invoice";
import { ArrowUpDown } from "lucide-react";
const statusStyles: Record<InvoiceStatus, string> = {
paid: "bg-green-100 text-green-800",
pending: "bg-yellow-100 text-yellow-800",
overdue: "bg-red-100 text-red-800",
};
const statusLabels: Record<InvoiceStatus, string> = {
paid: "مدفوع",
pending: "معلق",
overdue: "متأخر",
};
export const columns: ColumnDef<Invoice>[] = [
{
id: "select",
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
className="h-4 w-4 rounded border-gray-300"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "invoiceNumber",
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-1 font-medium"
>
رقم الفاتورة
<ArrowUpDown className="h-4 w-4" />
</button>
),
},
{
accessorKey: "client",
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-1 font-medium"
>
العميل
<ArrowUpDown className="h-4 w-4" />
</button>
),
},
{
accessorKey: "email",
header: "البريد الإلكتروني",
enableHiding: true,
},
{
accessorKey: "amount",
header: ({ column }) => (
<button
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-1 font-medium"
>
المبلغ
<ArrowUpDown className="h-4 w-4" />
</button>
),
cell: ({ row }) =>
new Intl.NumberFormat("ar-TN", {
style: "currency",
currency: "TND",
}).format(row.getValue("amount")),
},
{
accessorKey: "status",
header: "الحالة",
cell: ({ row }) => {
const status = row.getValue("status") as InvoiceStatus;
return (
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${statusStyles[status]}`}
>
{statusLabels[status]}
</span>
);
},
},
{
accessorKey: "dueDate",
header: "تاريخ الاستحقاق",
cell: ({ row }) =>
new Date(row.getValue("dueDate")).toLocaleDateString("ar-TN"),
},
];الخطوة 4: بناء مكون الجدول الأساسي
هنا يتجلى TanStack Table. خطاف useReactTable يربط جميع الميزات معاً:
// components/data-table/DataTable.tsx
"use client";
import { useState } from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
SortingState,
ColumnFiltersState,
VisibilityState,
RowSelectionState,
} from "@tanstack/react-table";
import { columns } from "./columns";
import { Invoice } from "@/types/invoice";
interface DataTableProps {
data: Invoice[];
}
export function DataTable({ data }: DataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [globalFilter, setGlobalFilter] = useState("");
const table = useReactTable({
data,
columns,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: { pageSize: 10 },
},
});
return (
<div className="space-y-4" dir="rtl">
{/* شريط الأدوات */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative max-w-sm w-full">
<input
type="text"
placeholder="البحث في جميع الأعمدة..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="w-full px-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
className="flex items-center gap-2 px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
>
تصدير CSV
</button>
</div>
{/* الجدول */}
<div className="rounded-lg border border-gray-200 overflow-hidden">
<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-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={`hover:bg-gray-50 transition-colors ${
row.getIsSelected() ? "bg-indigo-50" : ""
}`}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 whitespace-nowrap text-right">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
) : (
<tr>
<td
colSpan={columns.length}
className="px-4 py-8 text-center text-gray-500"
>
لا توجد فواتير.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* الترقيم */}
<div className="flex items-center justify-between text-sm text-gray-600">
<span>
عرض {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}–
{Math.min(
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length
)} من {table.getFilteredRowModel().rows.length} فاتورة
</span>
<div className="flex items-center gap-2">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="px-3 py-1 border rounded disabled:opacity-40"
>
السابق
</button>
<span>
صفحة {table.getState().pagination.pageIndex + 1} من {table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="px-3 py-1 border rounded disabled:opacity-40"
>
التالي
</button>
</div>
</div>
</div>
);
}الخطوة 5: التكامل مع الصفحة الرئيسية
// app/page.tsx
import { DataTable } from "@/components/data-table/DataTable";
import { invoices } from "@/lib/data";
export default function InvoiceDashboard() {
return (
<main className="container mx-auto px-4 py-8" dir="rtl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">الفواتير</h1>
<p className="mt-1 text-sm text-gray-500">
إدارة وتتبع جميع فواتير عملائك.
</p>
</div>
<DataTable data={invoices} />
</main>
);
}الخطوة 6: ترقيم الصفحات من جانب الخادم
للمجموعات الكبيرة من البيانات (آلاف الصفوف)، انقل الفرز والترقيم إلى الخادم. أخبر TanStack Table أنه في الوضع اليدوي:
const table = useReactTable({
data,
columns,
// إخبار الجدول بعدم ملكيته للفرز والترقيم
manualSorting: true,
manualPagination: true,
// إجمالي عدد السجلات من الخادم
rowCount: totalCount,
state: { sorting, pagination },
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
});ثم اجلب البيانات من جانب الخادم باستخدام معاملات الفرز والصفحة:
// app/api/invoices/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const page = Number(searchParams.get("page") ?? 1);
const pageSize = Number(searchParams.get("pageSize") ?? 10);
const sort = searchParams.get("sort") ?? "dueDate";
const dir = searchParams.get("dir") ?? "asc";
const data = await db.invoice.findMany({
orderBy: { [sort]: dir },
skip: (page - 1) * pageSize,
take: pageSize,
});
const total = await db.invoice.count();
return NextResponse.json({ data, total });
}اختبار التطبيق
شغِّل خادم التطوير وتحقق من كل ميزة:
npm run devقائمة التحقق:
- انقر على رؤوس الأعمدة — يجب أن تُرتب الصفوف تصاعدياً ثم تنازلياً
- اكتب في مربع البحث العالمي — يجب أن تُصفى الصفوف عبر جميع الأعمدة
- استخدم منتقي حجم الصفحة وأزرار التنقل — يجب أن يعمل الترقيم بشكل صحيح
- انقر مربعات الاختيار — يجب أن تُبرز الصفوف المحددة باللون النيلي
استكشاف الأخطاء وإصلاحها
الجدول لا يعرض صفوفاً بعد التصفية:
تأكد من تمرير getFilteredRowModel إلى useReactTable. بدونه، لا تأثير للتصفية.
الفرز لا يعمل:
تأكد من وجود getSortedRowModel في الخطاف وأن رأس العمود يستدعي column.toggleSorting().
أخطاء TypeScript في accessorKey:
يجب أن يستخدم تعريف العمود ColumnDef<YourType> — تأكد من تطابق النوع العام مع نوع بياناتك.
الخطوات التالية
الآن بعد أن أصبح لديك جدول بيانات متكامل الميزات، فكِّر في هذه الإضافات:
- توسيع الصفوف — استخدم
getExpandedRowModelلعرض لوحات تفاصيل أسفل الصفوف - إعادة ترتيب الأعمدة بالسحب والإفلات — دمجه مع
@dnd-kit/core - التمرير الافتراضي — استخدم
@tanstack/react-virtualلتصيير الصفوف المرئية فقط، مما يدعم الملايين من السجلات - استمرار الحالة في رابط URL — تسلسل الفرز والتصفية والصفحة إلى
searchParamsلعروض جدول قابلة للمشاركة والإشارة المرجعية
لاستكمال منظومة TanStack، ادمج هذا الجدول مع TanStack Query لتغذية بيانات حية في أعمدتك.
الخلاصة
يمنحك TanStack Table تجربة جداول بيانات بمستوى الإنتاج دون أن يحبسك في مكتبة مكونات صارمة. بامتلاكك طبقة التصيير، يمكنك مطابقة أي نظام تصميم وتنفيذ مُصيِّرات خلايا مخصصة والتوسع إلى مصادر بيانات من جانب الخادم — كل ذلك مع استدلال TypeScript الكامل يوجه كل تعريف عمود.