Advanced Features — grouping, expanding, virtualizing
row grouping, row expanding, faceted filter, virtualized rows, sticky header, sorting নিয়ে advanced কৌশল, accessibility ও চূড়ান্ত চেকলিস্ট — বাংলায়।
প্রতিদিনের কাজ শেষ। এই শেষ অধ্যায়ে সেই সব ফিচার যেগুলো মাঝে মাঝে লাগে, কিন্তু সবসময় লাগে না। না পড়লেও চলবে, পড়লে অনেক সমস্যা নিজে থেকেই সমাধান হবে।
Row grouping
একই category-র পণ্যগুলো একত্রে দেখানো। grouping state সাধারণত server-এর মতোই এখানে client-side হয়।
const [grouping, setGrouping] = useState<GroupingState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getGroupedRowModel: getGroupedRowModel(), // ← এটা ছাড়া grouping হবে না
getExpandedRowModel: getExpandedRowModel(),
state: { grouping, expanded },
onGroupingChange: setGrouping,
onExpandedChange: setExpanded,
});
// group করা শুরু
table.setGrouping([{ id: "category" }]);
// aggregate
column.getAggregationFn();
row.getValue(columnId); // group row-এ এটাই গণনীত মানGrouping-এর জন্য কলাম প্রস্তুত
{
accessorKey: "price",
aggregationFn: "sum", // built-in: sum, min, max, mean, count…
header: "মোট মূল্য",
cell: ({ getValue, row }) => {
// group header row-এ aggregate দেখায়
if (row.getIsGrouped()) {
return (
<span className="font-semibold">
{formatPrice(getValue<number>())}
</span>
);
}
return formatPrice(row.original.price);
},
}Group header-এর মান bold দেখাতে row.getIsGrouped()-এ শর্ত দিয়ে className বদলান — ঠিক উপরের মতো। TanStack group row চেনায়, কিন্তু চেনাতেই row.getIsGrouped() আর row.getIsGroupedColumn() ব্যবহার করুন।
// group header row দেখানো
table.getRowModel().rows.map((row) => {
if (row.getIsGrouped()) {
return (
<TableRow className="bg-muted/50 hover:bg-muted/50">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
}
return (
<TableRow className={row.getIsSubRow() ? "pl-6" : undefined}>
{/* … */}
</TableRow>
);
})}grouping চালু থাকলে sorting আর আগের ক্রমে কাজ করে না — এখন সেটা group row-গুলোর ক্রম ঠিক করে। মানে sorting: [{ id: "category", desc: true }] মানে "ক্যাটাগরি অনুসারে group সাজাও", পণ্য সাজাও না।
Row expanding — sub-row
ক্যাটাগরির নিচে সেই ক্যাটাগরির পণ্য — এটা grouping; আর একটি order-এর নিচে তার line items — এটা expanding। দুটো আলাদা feature, মাঝে মাঝে গুলিয়ে যায়।
// ① sub-row ডেটা দরকার
type Order = {
id: string;
total: number;
items: { id: string; name: string; qty: number }[];
};
// ② expanded state
const [expanded, setExpanded] = useState<ExpandedState>({});
// { "o-01": true, "o-05": true }
const table = useReactTable({
data: orders,
columns,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
state: { expanded },
onExpandedChange: setExpanded,
// তিনটি জিনিস লাগে
getSubRows: (row) => row.items, // sub-row কোথা
expandRows: true, // rendering-এ খোলা সারি
});// একটা row খুলে বন্ধ করা
row.toggleExpanded();
row.getIsExpanded();
row.getCanExpand();
row.getLeafRows(); // সব স্তরের শেষ পর্যন্ত শুধু leaf
row.getParentRow();
// পুরোটা খুলে/বন্ধ
table.getIsAllRowsExpanded();
table.toggleAllRowsExpanded();
table.getCanExpandRows(); // একটা sub-row আছে কিনা// expand করার বোতাম
cell: ({ row }) =>
row.getCanExpand() ? (
<Button
variant="ghost"
size="icon-sm"
onClick={row.getToggleExpandedHandler()}
aria-label={row.getIsExpanded() ? "ভাঙুন" : "খুলুন"}
>
{row.getIsExpanded()
? <ChevronDown className="size-4" />
: <ChevronRight className="size-4" />}
</Button>
) : (
<span className="w-8" /> // ← সারিগুলো align না হলে ঝাঁপায়
)Sub-row শুধু এক স্তরের হলে সহজ। বহু স্তরের হলে indentation বাড়াতে row.depth ব্যবহার করুন।
<TableCell style={{ paddingLeft: `${row.depth * 24 + 16}px` }}>
…
</TableCell>getExpandedRowModel() আর expandRows: true দুটোই দিতে হবে, আর getSubRows()-এ সঠিক function-এ পাঠাতে হবে। ভুল function দিলে getCanExpand() সবসময় false দেবে — চুপচাপ, কোনো error নেই।
Faceted filter
একই সাথে দুটো filter UI দেখান — category বাছুন, তারপর price range-এর অপশন শুধু ওই category-র।
const table = useReactTable({
// …
getFilteredRowModel: getFilteredRowModel(),
getFacetedRowModel: getFacetedRowModel(), // ← এটা ছাড়া কাজ হবে না
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
manualFiltering: true, // অন্য filter manual হলে এগুলো নিজে থেকেই হবে
state: { columnFilters },
onColumnFiltersChange: setColumnFilters,
});// কোন কলামে কোন অপশন বাকি আছে
table.getColumn("category")?.getFacetedUniqueValues();
// Map { electronics: 4, clothing: 3, books: 5 }
// অন্য filter বাদ দিয়ে সংখ্যা
table.getColumn("price")?.getFacetedMinMaxValues();
// [0, 200000]Faceted model ডিফল্ট হিসেবে অন্য সব filter উপেক্ষা করে — নিজের filter বাদ দেয়, বাকিগুলোর উপর হিসাব করে। এটাই চাওয়া আচরণ।
Virtualized rows
৫০০০ row একসাথে DOM-এ বসালে browser ধাঁধায় — স্ক্রল লাগে না, মেমরি ফুরিয়ে যায়। @tanstack/react-virtual ব্যবহার করলে শুধু দৃশ্যমান অংশই DOM-এ থাকে।
pnpm add @tanstack/react-virtual"use client";
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
export function VirtualizedTable({ table }: { table: Table<Product> }) {
const parentRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44, // এক row-এর আনুমানিক উচ্চতা (px)
overscan: 10, // উপরে-নিচে ১০টা extra row রেন্ডার
});
return (
<div
ref={parentRef}
className="h-[600px] overflow-auto" // ← উচ্চতা দরকার
>
<table className="w-full" style={{
// ⚠️ <table> ব্যবহার করলে এটা সবসময় লাগবে
display: "grid",
}}>
<tbody style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<tr
key={row.id}
style={{
display: "flex",
position: "absolute",
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
}}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="flex-1 px-4 py-2">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}shadcn-এর Table <table>-এর সাধারণ display: table CSS দেয়, যা virtual row-এর absolute positioning-এর সাথে কাজ করে না। তাই virtualization দিলে display: grid/flex ব্যবহার করতে হয়, অথবা অন্য virtualized table library (TanStack Virtual + custom markup) বেছে নিতে হয়।
শুধু ৫০০-১০০০ row পর্যন্ত virtualization ছাড়াই ঠিকঠাক চলে। এর বেশি হলে দরকার — এবং server-side pagination-এ সাধারণত দরকারই হয় না।
Sticky header
<TableHeader className="sticky top-0 z-20 bg-background">
{table.getHeaderGroups().map((group) => (
<TableRow key={group.id}>
{group.headers.map((header) => (
<TableHead key={header.id}>
…
</TableHead>
))}
</TableRow>
))}
</TableHeader>sticky কাজ করার জন্য কোনো ancestor-এ overflow: hidden বা overflow: auto থাকলে সমস্যা হয় — sticky তখন সেই container-এর মধ্যেই আটকায়। <Table>-এর wrapper-এ overflow না থাকা দরকার।
Accessibility
Headless মানে accessibility-ও আপনার দায়িত্ব। Table-এর জন্য মূল কয়েকটি:
<table>
{/* টেবিলের একটি বর্ণনা */}
<caption className="sr-only">
পণ্যের তালিকা, মোট {total}টি
</caption>
{/* sortable header-এ state ঘোষণা */}
<th
aria-sort={
column.getIsSorted() === "asc"
? "ascending"
: column.getIsSorted() === "desc"
? "descending"
: "none"
}
>
…
</th>
{/* পেজ কন্ট্রোলে বর্তমান অবস্থা */}
<span aria-live="polite">
পেজ {pageIndex + 1}, মোট {pageCount}টির মধ্যে
</span>
</table>// row selection বোঝানো
<TableRow aria-selected={row.getIsSelected() || undefined}>
…
</TableRow>
// filter-এর ফলাফল live ঘোষণা
<div role="status" aria-live="polite">
{filteredCount}টির মধ্যে {filteredCount}টি পাওয়া গেছে
</div>| কী | কেন |
|---|---|
aria-sort | screen reader সাজানোর অবস্থা বলে |
aria-selected | বেছে নেওয়া row বোঝায় |
aria-live | পেজ/ফলাফল বদলালে ঘোষণা হয় |
caption (sr-only) | টেবিলটা কী, তা বোঝায় |
aria-label checkbox-এ | কোন row বলে বোঝায় |
scope="col" | header কোন কলামের |
Sorting-এর advanced কৌশল
// ১. default sort রাখতে
initialState: {
sorting: [{ id: "createdAt", desc: true }],
}
// ২. sort-এর অবস্থা URL-এ
useEffect(() => {
const first = sorting[0];
const value = first ? `${first.id}:${first.desc ? "desc" : "asc"}` : null;
setSort(value, { scroll: false });
}, [sorting]);
// ৩. মাত্র ১০টা কলামের বেশি sort বন্ধ
maxMultiSortColCount: 3,
// ৪. যাচাই না করে দেখানো
enableSortingRemoval: true, // ৩য় ক্লিকে sort সরে যাবেMutation-এর পর selection ও পেজ ঠিক করা
// একটি মুছে ফেললে last page-এ একটাই row বাকি থাকতে পারে
const mutation = useMutation({
mutationFn: (id: string) => api.delete(`/products/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productKeys.all });
// শেষ পেজে শূন্য হয়ে গেলে এক পেজ পিছনে
const remaining = table.getRowModel().rows.length - 1;
if (remaining === 0 && pagination.pageIndex > 0) {
setPagination((old) => ({ ...old, pageIndex: old.pageIndex - 1 }));
}
},
});শেষ পেজে ১টি row মুছে দিলে খালি পেজ দেখাবে — অনেক অ্যাপে এটা ছোট কিন্তু ক্রণ ঘটা একটা বাগ। pageIndex এক কমিয়ে দিলে ব্যবহারকারী খালি পেজ দেখে না ছুটে অপেক্ষা করতে হয় না।
চূড়ান্ত চেকলিস্ট
| কাজ | মনে রাখবেন |
|---|---|
| base | getCoreRowModel() + getRowId |
| client-side | ৪টি row model যোগ করুন |
| server-side | manual* + rowCount + শুধু core |
| filter UI | খালি মানে return true |
| column | enableSorting/Hiding: false display-এ |
| performance | columns বাইরে, keepPreviousData, debounce |
| UX | isPending আর isFetching আলাদা জায়গায় |
| a11y | aria-sort, aria-live, caption |
| storage | visibility/order/pinning localStorage-এ |
সাধারণ ভুল সংক্ষেপে
| ভুল | ফল | সমাধান |
|---|---|---|
getRowId নেই | selection ভাঙে | (row) => row.id |
| manual + row model দুটোই | কিছু হয় না | manual-এ model বাদ |
rowCount: 0 | "পেজ 1 / 0" | ?? -1 |
pageIndex সরাসরি API | ০ পেজ | + 1 |
| filter-এ page reset নেই | খালি পেজ | useEffect |
| sticky-তে bg নেই | লেখা ভেসে যায় | bg-background |
colSpan={columns.length} | লুকানো কলামে ভাঙা | getVisibleLeafColumns().length |
Object.keys(rowSelection) | ভুল id | getSelectedRowModel() |
a > b দিয়ে বাংলা sort | ভুল ক্রম | localeCompare("bn-BD") |
শেষ কথা
TanStack Table শিখতে গুরুত্বপূর্ণ তিনটি ব্যাপার আছে।
প্রথম — headless মানে দায়িত্ব আপনার। state ও logic লাইব্রেরি দেয়, markup, loading state, empty state, accessibility সব আপনাকে লিখতে হয়। এই অধ্যায়গুলোতে যত code দেখলেন, বাস্তবে সবটাই আপনার লেখা।
দ্বিতীয় — row model-এর পাইপলাইন মনে রাখুন। filter → sort → expand → paginate। ক্রম গুলিয়ে গেলে বাগ আসে, আর কেন আসে তা বুঝতে পারবেন না।
তৃতীয় — controlled state-ই বাস্তব। ছোট ডেমোতে initialState যথেষ্ট, কিন্তু যোগানোর সাথে সাথে state + on*Change লাগবেই — কারণ তখনই sorting/filtering server-এ যায়, URL-এ যায়, আর অন্য component-ও পড়তে পারে।
TanStack Table-এর TanStack Form ও TanStack Virtual একই লেখকের তৈরি, একই মনোভাব — একটা state ধরে রাখে, বাকিটা আপনার। এগুলো শিখলে TanStack-এর পুরো টুলকিট হাতে চলে আসবে।