Server-side Mode — সার্ভার থেকে ডেটা
manualSorting/manualFiltering/manualPagination, rowCount, state থেকে query params, page reset, TanStack Query-এর সাথে সেতু, debounce ও URL sync — বাংলায়।
আগের অধ্যায়গুলোতে সব হিসাব browser-এ হতো — তা কয়েকশো row-এর জন্য যথেষ্ট। কিন্তু ৫ লাখ row হলে যা ঘটবে: পুরো ডেটা download, প্রতিবার সাজানো, আর কয়েক সেকেন্ড লাগবে। এই অধ্যায়ে সেটাই বদলাচ্ছি — হিসাব সার্ভারে, table শুধু state ধরে রাখবে।
মূল পার্থক্য
TanStack-এ দুটি পুরো আলাদা জগত আছে। বুঝে নিলে বাকি সব সহজ।
| client-side | server-side | |
|---|---|---|
| ডেটা | পুরোটা | শুধু current page |
| সাজানো | getSortedRowModel() | server-এর কাজ |
manual* | false | true |
| row model | ৪-৫টি | শুধু getCoreRowModel() |
rowCount | দরকার নেই | আবশ্যক |
| কত row বাকি | জানা | total থেকে |
manual flag
const table = useReactTable({
data: data?.items ?? [],
columns,
getRowId: (row) => row.id,
// state table-এর, কিন্তু খোলা (controlled)
state: { sorting, pagination, globalFilter },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onGlobalFilterChange: setGlobalFilter,
// "আমি নিজে transform করব না" — শুধু state ধরে রাখুন
manualSorting: true,
manualFiltering: true,
manualPagination: true,
// মোট কত row আছে (API থেকে)
rowCount: data?.total ?? -1,
// ⚠️ শুধু core — বাকি model দিলে কাজ হবে না
getCoreRowModel: getCoreRowModel(),
});| flag | state বদলায় | ডেটা transform করে |
|---|---|---|
manualSorting | header-এর অ্যারি বদলায় | না |
manualFiltering | filter state বদলায় | না |
manualPagination | পেজ বদলায় | না |
manualSorting: true দিলে getSortedRowModel() উপেক্ষা করা হয়। দুটো একসাথে দিলে state বদলাবে কিন্তু কোনো দৃশ্যমান পরিবর্তন হবে না — বাগ খুঁজতে কয়েক ঘণ্টা লাগে। Rule: manual* = true হলে ঐ feature-এর row model বাদ দিন।
State থেকে query params — সেতু
এটাই পুরো server-side table-এর মূল কৌশল। Table-এর state থেকে params বানাই, params থেকে নতুন ডেটা আসে।
export function ProductsTable() {
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true }, // নতুনগুলো আগে
]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string | undefined>();
// ① table state → API params
const params = {
page: pagination.pageIndex + 1, // 0-based → 1-based
pageSize: pagination.pageSize,
search: search || undefined,
category,
sortBy: sorting[0]?.id as ProductSortField | undefined,
sortOrder: sorting[0]?.desc ? "desc" : "asc",
};
// ② params → ডেটা
const { data, isPending, isFetching } = useProducts(params);
// ③ ডেটা → table
const table = useReactTable({
data: data?.items ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
state: { sorting, pagination, globalFilter: search, columnFilters: … },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onGlobalFilterChange: setSearch,
manualSorting: true,
manualFiltering: true,
manualPagination: true,
rowCount: data?.total ?? -1,
});
}params প্রতি রেন্ডারে নতুন object হলেও TanStack Query-র queryKey গভীরভাবে তুলনা করে — তাই একই মানে নতুন request যায় না।
Query key
// API call
const productKeys = {
all: ["products"] as const,
list: (params: ProductQueryParams) =>
[...productKeys.all, "list", params] as const,
};
export function useProducts(params: ProductQueryParams) {
return useQuery({
queryKey: productKeys.list(params),
queryFn: ({ signal }) =>
axios
.get<ProductsResponse>("/api/products", {
params,
signal, // ← request বাতিল হলে query-ও বাতিল
})
.then((res) => res.data),
placeholderData: keepPreviousData, // ← পরের পেজ আসা পর্যন্ত পুরোনো দেখায়
});
}এটা ছাড়া পেজ বদলালে table এক মুহূর্তের জন্য ফাঁকা হয়ে যায়। keepPreviousData দিলে পুরোনো ডেটা দেখিয়ে উপরে একটা ছোট loading indicator দেখায় — isFetching ব্যবহার করে। এটা server-side table-এর সবচেয়ে জরুরি একটা সেটিং।
pageIndex কেন reset করতে হয়
Client mode-এ TanStack নিজেই autoResetPageIndex চালু রাখে — filter/sort বদলালে প্রথম পেজে ফিরে যায়। কিন্তু manualPagination: true দিলে এটা নিজে থেকেই `false` হয়ে যায়। ফলে ৫ নম্বর পেজে থেকে filter বদলালে খালি পেজ পান।
useEffect(() => {
setPagination((old) =>
old.pageIndex === 0 ? old : { ...old, pageIndex: 0 },
);
}, [search, category, sorting]);যে কিছুতে ভ্যালু বদলালে page অর্থহীন হয়, ওগুলোই dependency। sorting একটা array — তাই সরাসরি useEffect dependency হিসেবে দিলে প্রতি রেন্ডারে নতুন identity পেয়ে চলে যায়। sorting[0]?.id ও sorting[0]?.desc বের করে সেগুলো dependency দিন।
const sortKey = sorting[0]?.id;
const sortDir = sorting[0]?.desc;
useEffect(() => {
setPagination((old) =>
old.pageIndex === 0 ? old : { ...old, pageIndex: 0 },
);
}, [search, category, sortKey, sortDir]); // ✅ পরিষ্কারold ফেরত দেওয়া জরুরি — তাহলে ইতিমধ্যে 0 থাকলে state unchanged থাকে, re-render হয় না, আর সত্যিই 0 না হলে বদলায়।
debounced search
সার্চ বক্সে প্রতি keystroke-এ request গেলে ১০ অক্ষর লিখতে ১০টা request — আর প্রতিটি আগেরটা বাতিল। Debounce ছাড়া এটা হয়।
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}const [searchInput, setSearchInput] = useState("");
const search = useDebouncedValue(searchInput, 350);
// query-তে debounced মান যায়
const params = { …, search: search || undefined };
// input-এ তাৎক্ষণিক মান বসে — দ্রুত টাইপ feel হয়
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>// input লিখছে, request যাচ্ছে ৩৫০ms পরে
setSearchInput("lap") // ← তাৎক্ষণিক: input দেখায়
// ← ৩৫০ms পরে: search = "lap" → queryKey বদলায়
// ← "laptop" লিখলে আগের timer বাতিলURL-এ state রাখা
Table-এর state URL-এ থাকলে share-যোগ্য, bookmark করা যায়, আর back বাটন কাজ করে। এটা nuqs বা নিজে URLSearchParams দিয়ে করা যায়।
"use client";
import { useQueryState } from "nuqs/sticky";
import type { SortingState } from "@tanstack/react-table";
export function ProductsTable() {
const [page, setPage] = useQueryState("page", {
parse: Number,
defaultValue: 1,
});
const [sort, setSort] = useQueryState("sort");
const [q, setQ] = useQueryState("q");
// URL → table state
const [sorting, setSorting] = useState<SortingState>(() =>
sort ? [{ id: sort.split(":")[0], desc: sort.endsWith(":desc") }] : [],
);
const [pagination, setPagination] = useState({
pageIndex: page - 1,
pageSize: 10,
});
// table state → URL
const handleSortingChange = (updater: OnChangeFn<SortingState>) => {
setSorting((old) => {
const next = typeof updater === "function" ? updater(old) : updater;
const first = next[0];
setSort(first ? `${first.id}:${first.desc ? "desc" : "asc"}` : null);
return next;
});
};sticky মানে ব্যবহারকারী সরালে default value-তে ফিরে যায়, history.replace হয় না (back বাটর কাজ করে)। শুধু search box-এর মতো text input-এ push লাগে, সেটা আলাদা।
rowCount-এর সূক্ষ্মতা
// ✅ মোট ঠিক জানা আছে
rowCount: data?.total ?? -1,
// ⚠️ ফিল্টার বদলালে total নতুন — loading চলাকালীন পুরোনো total থাকে
rowCount: data?.total ?? 0, // ← 0 হলে getPageCount() = 0 হয়ে UI ভাঙেconst isPageCountSafe = !isPending && data?.total != null;
// পেজ কন্ট্রোল loading-এর সময় hide করা নিরাপদ
{isPageCountSafe && (
<TablePagination table={table} />
)}Loading চলাকালীন data undefined, তাই ?? 0 দিলে getPageCount() = 0 হয়ে যায় — "পেজ 1 / 0" দেখাবে। ?? -1 দিন (exact হিসাব হয় না) অথবা পেজ কন্ট্রোলটা loading-এর সময় লুকিয়ে দিন।
loading ও empty state
const rows = table.getRowModel().rows;
const colCount = table.getVisibleLeafColumns().length;
<TableBody>
{isPending ? (
// প্রথমবার — পুরো স্কেলেটন
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: colCount }).map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))
) : rows.length === 0 ? (
// ফলাফল নেই
<TableRow>
<TableCell colSpan={colCount} className="h-32 text-center">
<SearchX className="mx-auto mb-2 size-8 text-muted-foreground" />
<p className="font-medium">কোনো পণ্য পাওয়া যায়নি</p>
<p className="text-sm text-muted-foreground">
ফিল্টার বদলে দেখুন
</p>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={isFetching ? "opacity-60 transition-opacity" : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>isPending = প্রথমবার লোড (স্কেলেটন দেখান), isFetching = বারবার রিফেচ (পুরোনো ডেটা রেখে ম্লান করে)। দুটো এক জায়গায় ব্যবহার করবেন না।
API-তে query থেকে rowCount বের করা
import { keepPreviousData, useQuery } from "@tanstack/react-query";
type ProductSortField = "name" | "price" | "stock" | "createdAt";
type SortOrder = "asc" | "desc";
type ProductQueryParams = {
page: number;
pageSize: number;
search?: string;
category?: string;
sortBy?: ProductSortField;
sortOrder?: SortOrder;
};
type ProductsResponse = {
items: Product[];
total: number;
page: number;
pageSize: number;
};// Next.js route handler
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = Math.max(1, Number(searchParams.get("page") ?? 1));
const pageSize = Math.min(100, Number(searchParams.get("pageSize") ?? 10));
const search = searchParams.get("search") ?? undefined;
const sortBy = (searchParams.get("sortBy") ?? "createdAt") as ProductSortField;
const sortOrder = (searchParams.get("sortOrder") ?? "desc") as SortOrder;
const where = search
? { name: { contains: search, mode: "insensitive" } }
: {};
const [total, items] = await Promise.all([
prisma.product.count({ where }),
prisma.product.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { [sortBy]: sortOrder },
}),
]);
return Response.json({ items, total, page, pageSize });
}sortBy গ্রাহক থেকে আসে বলে whitelist করা জরুরি — নইলে যে কেউ ?sortBy=password পাঠিয়ে sensitive field দেখতে পারে।
Selection-এর বিশেষ ঘটনা
Server-এ এক পেজের row বেছে নিলে সেগুলো ইচ্ছাকৃতভাবেই এক পেজেই থাকবে — পরের পেজে ফিল্টার হবে না। কারণ table জানে না মোট কয়টি row মিলেছে। এটা getIsAllRowsSelected()-এর উপর নির্ভর করে।
const totalMatching = data?.total ?? 0;
const onThisPage = table.getRowModel().rows.length;
// পরের পেজেও বেছে থাকবে কিনা
const isSelectingAll = table.getIsAllRowsSelected();
// "সব ১২৮৪টি বেছে নিন" লিংক দেখাতে
{!table.getIsAllPageRowsSelected() && totalMatching > onThisPage && (
<Button
variant="link"
onClick={() => table.toggleAllRowsSelected(true)}
>
সব {totalMatching}টি বেছে নিন
</Button>
)}toggleAllRowsSelected(true) server mode-এ হাজার হাজার id state-এ ঢোকায়। বড় অ্যাপে বদলে একটা পতাকা রাখুন — const [allMatching, setAllMatching] = useState(false) — আর mutation-এ allMatching ? where: {…} : { id: { in: selectedIds } } ব্যবহার করুন।
সম্পূর্ণ চেকলিস্ট
| কাজ | কেন |
|---|---|
manual* = true | নিজে transform হবে না |
শুধু getCoreRowModel() | বাকি model কাজ করবে না |
getRowId | selection ও key ঠিক থাকে |
rowCount: total ?? -1 | page count হিসাব |
pageIndex + 1 | off-by-one এড়ানো |
filter-এ pageIndex reset | খালি পেজ এড়ানো |
| debounce search | অপ্রয়োজনীয় request বন্ধ |
keepPreviousData | পেজ বদলে ফাঁকা হয় না |
isPending/isFetching আলাদা | UX ঠিক থাকে |
| sortBy whitelist | নিরাপত্তা |
সারসংক্ষেপ
- সব হিসাব server-এ, table শুধু state ও button
manual*+ শুধুgetCoreRowModel()+rowCount— এই তিনটিই মূল- state → params → query → ডেটা → table, এই চক্রটাই সব
- manual mode-এ auto page reset হয় না, করে দিতে হয়
- loading-এর দুই ধরন আলাদা জায়গায়
পরবর্তী
সবচেয়ে বেশি ব্যবহৃত কাজ শেষ। শেষ অধ্যায়ে বাকি যা বাকি — row grouping, row expanding, faceted filter, virtualized row, sticky header আর accessibility।