TanStack Table — গভীরে (Explore)
row model, client-side ও server-side mode, controlled state, rowCount, column visibility ও row selection — বাংলায়।
আগের অধ্যায়গুলো table-এর মূল শিখিয়েছিল — data, columns আর getCoreRowModel। এই অধ্যায়ে সেই মূলের উপরে থাকা বাকি সব স্তর: data কীভাবে ভেতরে ভেতরে ঢোকে, sorting/filtering/pagination কখন table-এর নিজের কাজ আর কখন server-এর, এবং selection/column visibility-র মতো feature কীভাবে যোগ হয়।
লাইভ ডেমো: TanStack Table + shadcn + Query পেজে একই ডেটা দুইভাবে দেখানো হয়েছে — একটা পুরোপুরি client-side, আরেকটা TanStack Query-ও route handler-যুক্ত server-side। অধ্যায়ের প্রতিটি কোড block-ই সেই ডেমোর প্রকৃত কোড।
এই অধ্যায়ে কী কী নতুন
| আগের অধ্যায়ে ছিল | এই অধ্যায়ে যোগ হচ্ছে |
|---|---|
getCoreRowModel | getSortedRowModel, getFilteredRowModel, getPaginationRowModel |
| server-side sort/pagination (শুধু query param) | manualSorting / manualPagination / rowCount — দুই পাশের সংযোগ |
key={row.id} (index-based id) | getRowId — স্থায়ী row id |
| শুধু data দেখানো | row selection, column visibility, empty state |
Row model — data ভেতরে যায় কীভাবে
TanStack Table ডেটাকে একবারে রেন্ডার করে না। এটি একটা pipeline — প্রতিটি row model আগেরটার উপর থেকে কাজ করে, তাই সাজানোর ক্রম গুরুত্বপূর্ণ।
data (raw)
↓ getCoreRowModel → মূল row তৈরি
↓ getFilteredRowModel → search/filter মেরে বাকি
↓ getSortedRowModel → সাজানো
↓ getPaginationRowModel → কয়টি row দেখাবে
table.getRowModel().rowsশেষেরটা getRowModel() — যা রেন্ডারে ব্যবহার হয়। কিন্তু প্রতিটা ধাপের নিজস্ব accessor আছে: filtered-এর জন্য getFilteredRowModel(), sorted-এর জন্য `getSortedRowModel()।
| row model | কী করে | state |
|---|---|---|
getCoreRowModel() | মূল row বানায় | — |
getFilteredRowModel() | filter মেরে কমায় | globalFilter, columnFilters |
getSortedRowModel() | সাজায় | sorting |
getPaginationRowModel() | ছেঁকে নেয় | pagination |
Client-side — পুরো সেটআপ
ছোট ডেটা (কয়েকশো row) হলে সবকিছু browser-এ করানোই সবচেয়ে সহজ। চারটা row model দিলেই table নিজেই filter, sort ও paginate করে — কোনো server, কোনো query লাগে না।
"use client";
import { useState } from "react";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from "@tanstack/react-table";
export function ProductTable({ data, columns }: ProductTableProps) {
// যেসব state আমরা চাই, ওগুলো এখানে ধরে দিই
const [sorting, setSorting] = useState<SortingState>([
{ id: "price", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const table = useReactTable({
data,
columns,
getRowId: (row) => row.id,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
});
}state দিয়ে যে slice-গুলো ধরে দিলাম, onXYChange handler-ই সেগুলোর মালিক — table নিজে বদলায় না। বাকি slice (যেমন rowSelection`) table নিজের মধ্যেই সামলায়। এটাই controlled ও uncontrolled state-এর ভাগাভাগি।
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>রেন্ডার কোড আগের অধ্যায়ের হুবহু — কারণ headless মানেই এটাই: row model বদলালেও মার্কআপে হাত দিতে হয় না।
Server-side — manual mode
আগের pagination ও sorting অধ্যায়ে sort state Query-এর উপরে ছিল, table-এর বাইরে। ওটাই সঠিক উপায়, কিন্তু ফলে table কিছুই জানে না — যেমন মোট কত পেজ, এখন কোন পেজে আছি। manual mode এই সেতুটাই তৈরি করে।
const table = useReactTable({
data: data?.items ?? [],
columns: productColumns,
getRowId: (row) => row.id,
// state table-এর, কিন্তু খোলা (controlled)
state: { sorting, pagination },
onSortingChange: setSorting,
onPaginationChange: setPagination,
// "আমি নিজে transform করব না" — শুধু state রাখব
manualSorting: true,
manualFiltering: true,
manualPagination: true,
// মোট কত row আছে (API থেকে) — page count হিসাবের জন্য
rowCount: data?.total ?? -1,
// এখন শুধু core যথেষ্ট — বাকি model দিলে কাজ হতো না
getCoreRowModel: getCoreRowModel(),
});| কী | manual mode-এ কী হয় |
|---|---|
manualSorting | header-এ ক্লিক করলে state বদলায়, কিন্তু row সাজায় না |
manualFiltering | filter state বদলায়, কিন্তু filter করে না |
manualPagination | page বদলায়, কিন্তু slice করে না |
rowCount | getPageCount() এটা থেকে হিসাব হয় |
manual mode-এ getSortedRowModel() বা getPaginationRowModel() দিলে দুটো কাজ করেই যাবে না — manual flag-ই তাকে উপেক্ষা করে। সেই অবস্থায় সাজানোর দায়িত্ব queryKey-র উপরে।
State উপরে উঠানো — table থেকে Query
এখন table-এর state (sorting, pagination) থেকে Query-এর params বানাই। State যেই না-ই বদলাক, queryKey বদলাবে, আর নতুন page আসবে — এটাই দুই লাইব্রেরির মাঝের সেতু।
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
// table-এর 0-based pageIndex → API-র 1-based page
const params: ProductQueryParams = {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: search || undefined,
sortBy: sorting[0]?.id as ProductQueryParams["sortBy"],
sortOrder: sorting[0]?.desc ? "desc" : "asc",
};
const { data, isPending, isFetching } = useProducts(params);০ vs ১ এই অফসেটটা সবচেয়ে বেশি ভুল হয়: table pageIndex 0 থেকে গোনে, আমাদের API 1 থেকে। তাই + 1 লাগেই।
Filter বদলালে প্রথম পেজে ফেরা
manualPagination: true দিলে table নিজে আর page reset করে না (autoResetPageIndex নিজে থেকেই false হয়ে যায়)। ফলে page ৫-এ থেকে filter বদলালে খালি পেজ পেতেন। তাই reset-টা আমাদের দায়িত্ব।
useEffect(() => {
setPagination((old) =>
old.pageIndex === 0 ? old : { ...old, pageIndex: 0 },
);
}, [search, category, status, sorting]);old ফেরত দিলে state বদলায় না, তাই অপ্রয়োজনীয় re-render-ও হয় না।
getRowId — স্থায়ী row id
row.id ডিফল্টভাবে row-এর index। ডেটা সাজালে বা pagination বদলালে index বদলে যায় — ফলে selection ভেঙে যায়, checkbox মিসম্যাচ হয়, animation ঝাঁপায়। getRowId সেটা ঠিক করে।
// ছাড়া: row.id = "0", "1", "2" → সাজালেই বদলে যায়
getRowId: (row) => row.id, // "p-01" — অপরিবর্তনীয়row selection, column pinning, expanded state বা server-side data — এগুলোর যেকোনো একটা থাকলেই getRowId বসান। ব্যবহার না হলেও বসিয়ে রাখলে কোনো ক্ষতি নেই, শুধু একটা option।
Column visibility
কোন কলাম দেখাবেন সেটাও একটা state — columnVisibility। এটার জন্য কোনো ফাইলে ছোঁয় না লাগে; শুধু getAllLeafColumns() ঘুরে প্রতিটির checkbox বানিয়ে toggleVisibility কল করলেই হয়।
export function ColumnVisibility({ table }: { table: Table<unknown> }) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
কলাম
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllLeafColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={(checked) =>
column.toggleVisibility(checked)
}
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}লুকানো কলাম আর row.getVisibleCells()-এ আসেই না — তাই rendering কোডে কোনো শর্ত লিখতে হয় না। getCanHide() false হলে (যেমন checkbox কলাম) সেটি তালিকায় আসবে না।
Row selection
Selection-এর প্রতিটি ধাপ table-ই সামলায়: row.getIsSelected(), row.toggleSelected(), আর পুরো পেজের জন্য table.getIsAllPageRowsSelected() / getIsSomePageRowsSelected()।
{
id: "select",
header: ({ table }) => {
const all = table.getIsAllPageRowsSelected();
const some = table.getIsSomePageRowsSelected();
return (
<Checkbox
aria-label="সব row নির্বাচন"
checked={all}
indeterminate={!all && some}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked)
}
/>
);
},
cell: ({ row }) => (
<Checkbox
aria-label={`${row.original.name} নির্বাচন`}
checked={row.getIsSelected()}
onCheckedChange={(checked) => row.toggleSelected(checked)}
/>
),
enableSorting: false,
enableHiding: false,
}এটি actions কলামের মতোই display column — accessorKey নেই, শুধু id। আর খেয়াল করুন: row.original.name-এর বদলে row.id ব্যবহার করা উচিত ছিল, কারণ id অপরিবর্তনীয় — getRowId না দিলে row.id মানে index।
// নির্বাচিত row-এর কাজ (bulk delete ইত্যাদি)
const selectedIds = Object.keys(table.getState().rowSelection);
const selectedRows = table.getSelectedRowModel().rows;Empty state ও loading
rows.length === 0 হলে একটা পুরো TableRow + colSpan দিয়ে খালি বার্তা দেখান — এটা না করলে table header-সহ ফাঁকা থেকে যায়, যা দেখতে ভাঙা মনে হয়।
{rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="h-24 text-center text-muted-foreground"
>
কোনো product মেলেনি।
</TableCell>
</TableRow>
) : (
rows.map((row) => <TableRow key={row.id}>…</TableRow>)
)}Server mode-এ loading-এর জন্য row নয়, cell-এর ভেতরে Skeleton বসান — তাহলে header ও কলামের স্থান নড়ে না। আর isFetching (বারবার refetch) আর isPending (প্রথমবার) আলাদা — দুটোর জায়গা আলাদা।
{isPending
? Array.from({ length: 5 }, (_, i) => (
<TableRow key={i}>
{table.getVisibleLeafColumns().map((column) => (
<TableCell key={column.id}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))
: rows.map((row) => …)}এক নজরে — কোন feature-এ কী লাগে
| কাজ | option | যা পড়বেন |
|---|---|---|
| সাজানো | getSortedRowModel | getSortedRowModel() |
| খুঁজে বের করা | getFilteredRowModel | getFilteredRowModel() |
| পেজ ভাগ | getPaginationRowModel | getRowModel() |
| সার্ভারে সাজানো | manualSorting | sorting[0]?.id → queryKey |
| সার্ভারে পেজ | manualPagination + rowCount | getPageCount() |
| স্থায়ী id | getRowId | row.id |
| কলাম লুকানো | columnVisibility | getVisibleCells() |
| row বাছাই | rowSelection | getSelectedRowModel() |
সাধারণ ভুল
| ভুল | ফল | সমাধান |
|---|---|---|
manualPagination + getPaginationRowModel দুটোই | কাজ করে না | manual mode-এ row model দেবেন না |
rowCount না দেওয়া | মোট ১ পেজ দেখায় | API-র total পাঠান |
getRowId না দেওয়া | সাজালে selection ভাঙে | getRowId: (row) => row.id |
| filter বদলেও pageIndex ০ না করা | খালি পেজ | useEffect-এ reset |
pageIndex সরাসরি API-তে | off-by-one | + 1 করে পাঠান |
colSpan={columns.length} | লুকানো কলাম থাকলে ভাঙা | table.getVisibleLeafColumns().length |
key={index} বা default row.id | re-render বাগ | getRowId |
TanStack Table state ও logic দেয়, markup-ওর দায়িত্ব আপনার — shadcn-র Table, TableHead ছাড়াও loading, empty state, এবং aria-label সব আপনাকেই লিখতে হবে। উপরের ডেমোতে ঠিক সেটাই করা হয়েছে: গুছানো shadcn markup + headless state।