Trong phát triển website hiện đại, việc triển khai phân trang, tìm kiếm và sắp xếp phía máy chủ trong một ứng dụng React đòi hỏi phải xem xét cẩn thận các yếu tố khác nhau như hiệu suất, trải nghiệm người dùng và khả năng bảo trì.
Trong hướng dẩn toàn diện này, chúng tôi sẽ triển khai liền mạch các chức năng phân trang, tìm kiếm và sắp xếp phía máy chủ để quản lý hiệu quả các tập dữ liệu lớn với sự hỗ trợ của TanStack’s react-table để quản lý dữ liệu bảng, sử dụng react-query để tìm nạp dữ liệu từ máy chủ và tích hợp react-router để điều hướng liền mạch giữa các màn hình khách nhau của ứng dụng của chúng tôi.
Setup
Hãy bắt đầu bằng cách tạo một ứng dụng React mới với Typescript, tận dụng Vite để phát triển nhanh chóng. Đối với phần backend, chúng tôi sẽ mô phỏng nhanh chóng dữ liệu bằng cách sử dụng json-server. Bạn có thể tìm thấy tất cả các dependencies cần thiết cho dự án này trong tệp package.json.
package.json
{ "name": "server-side-demo", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, "dependencies": { "@tanstack/react-query": "^5.24.1", "@tanstack/react-table": "^8.13.0", "axios": "^1.6.7", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.0.1", "react-router-dom": "^6.22.1", "clsx": "^2.1.0", "tailwind-merge": "^2.2.0" }, "devDependencies": { "@types/react": "^18.2.56", "@types/react-dom": "^18.2.19", "@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/parser": "^7.0.2", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.17", "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "postcss": "^8.4.35", "tailwindcss": "^3.4.1", "typescript": "^5.2.2", "vite": "^5.1.4" }
}
Hãy tạo một hàm tiện ích nhỏ kết hợp tailwind-merge với clsx để kết hợp class-name. Ngoài ra, chúng tôi sẽ triển khai chức năng debounce để tối ưu hiệu suất.
utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));
} /** * @param {Function} func * @param {number} delay * @param {{ leading?: boolean }} options */
export function debounce( // eslint-disable-next-line @typescript-eslint/ban-types func: Function, delay: number, { leading }: { leading?: boolean } = {}
) { let timerId: number | null; return (...args: unknown[]) => { if (!timerId && leading) { func(...args); } if (timerId) { clearTimeout(timerId); } timerId = setTimeout(() => func(...args), delay); };
}
Hãy tạo các custom hooks hữu ích cho dự án của chúng ta.
use-table.ts
import { useState } from "react";
import { type ColumnDef, type SortingState, getCoreRowModel, getSortedRowModel, useReactTable,
} from "@tanstack/react-table"; /** * Custom hook for created on top of tanstack table with sorting functionality. * @template T - Generics types. * @param {ColumnDef<T>[]} columns - Array of column definitions. * @param {T[]} data - Array of data items. * @returns {{ table: Table<T>, sorting: SortingState, setSorting: React.Dispatch<React.SetStateAction<SortingState>> }} */ export function useTable<T>(columns: ColumnDef<T>[], data: T[]) { const [sorting, setSorting] = useState<SortingState>([]); const table = useReactTable<T>({ data, columns, state: { sorting, }, manualSorting: true, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel<T>(), getSortedRowModel: getSortedRowModel<T>(), }); return { table, sorting, setSorting };
}
use-pagination.ts
import { useMemo } from "react"; type PaginationType = { totalCount: number; pageSize: number; siblingCount: number; currentPage: number;
}; export const DOTS = "..."; function range(start: number, end: number) { const length = end - start + 1; return Array.from({ length }, (_, index) => index + start);
} /** * Custom hook for generating pagination range based on the provided parameters. * @param {PaginationType} param - Object containing pagination parameters. * @returns {(number | string)[] | undefined} - Array representing the pagination range or undefined. */ export function usePagination({ totalCount, pageSize, siblingCount, currentPage,
}: PaginationType): (number | string)[] | undefined { // eslint-disable-next-line react-hooks/exhaustive-deps const paginationRange = useMemo(() => { const totalPageCount = Math.ceil(totalCount / pageSize); const totalPageNumbers = siblingCount + 5; /** * @description Case 1: If the number of pages is less than the page numbers we want to show in our paginationComponent, we return the range [1..totalPageCount] */ if (totalPageNumbers >= totalPageCount) { return range(1, totalPageCount); } const leftSiblingIndex = Math.max(currentPage - siblingCount, 1); const rightSiblingIndex = Math.min( currentPage + siblingCount, totalPageCount ); /** * @description We do not show dots just when there is just one page number * to be inserted between the extremes of sibling and the page limits i.e 1 and totalPageCount. * Hence we are using leftSiblingIndex > 2 and rightSiblingIndex < totalPageCount - 2 */ const shouldShowLeftDots = leftSiblingIndex > 2; const shouldShowRightDots = rightSiblingIndex < totalPageCount - 2; const firstPageIndex = 1; const lastPageIndex = totalPageCount; /** * @description Case 2: No left dots to show, but rights dots to be shown */ if (!shouldShowLeftDots && shouldShowRightDots) { const leftItemCount = 3 + 2 * siblingCount; const leftRange = range(1, leftItemCount); return [...leftRange, DOTS, totalPageCount]; } /** * @description Case 3: No right dots to show, but left dots to be shown */ if (shouldShowLeftDots && !shouldShowRightDots) { const rightItemCount = 3 + 2 * siblingCount; const rightRange = range( totalPageCount - rightItemCount + 1, totalPageCount ); return [firstPageIndex, DOTS, ...rightRange]; } /** * @description Case 4: Both left and right dots to be shown */ if (shouldShowLeftDots && shouldShowRightDots) { const middleRange = range(leftSiblingIndex, rightSiblingIndex); return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex]; } }, [totalCount, pageSize, siblingCount, currentPage]); return paginationRange;
}
Hãy bắt đầu tạo components thiết yếu để nâng cao dự án của chúng ta.
data-table.tsx
import { useEffect } from "react";
import { LuArrowUpDown } from "react-icons/lu";
import { useSearchParams } from "react-router-dom";
import { HiArrowDown, HiArrowUp } from "react-icons/hi";
import { SortingState, Table, flexRender } from "@tanstack/react-table"; interface DataTableProps<T> { table: Table<T>; sorting: SortingState;
} export function DataTable<T>({ table, sorting }: DataTableProps<T>) { const [searchParams, setSearchParams] = useSearchParams(); const params = new URLSearchParams(searchParams); useEffect(() => { if (sorting.length > 0) { params.set("page", "1"); params.set("sort_by", sorting[0].id); params.set("order_by", sorting[0].desc ? "desc" : "asc"); } else { params.delete("sort_by"); params.delete("order_by"); } setSearchParams(params); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sorting]); return ( <div className="mt-6 flow-root"> <div className="inline-block min-w-full align-middle"> <div className="rounded-lg bg-gray-50 p-2 md:pt-0"> <table className="min-w-full text-gray-900"> <thead className="rounded-lg text-left text-sm font-normal"> {table.getHeaderGroups().map((headerGroup) => ( <tr key={headerGroup.id} className="text-sm font-thin text-gray-600 py-8" > {headerGroup.headers.map((header) => ( <th key={header.id} colSpan={header.colSpan} className="px-3 py-5 font-medium whitespace-nowrap" > {header.isPlaceholder ? null : ( <div {...{ className: header.column.getCanSort() ? "flex items-center gap-x-2 cursor-pointer" : "", onClick: header.column.getToggleSortingHandler(), }} > <span className=""> {flexRender( header.column.columnDef.header, header.getContext() )} </span> {header.column.getIsSorted() ? ( { asc: <HiArrowUp />, desc: <HiArrowDown />, }[(header.column.getIsSorted() as string) ?? null] ) : ( <> {header.column.getCanSort() ? ( <LuArrowUpDown /> ) : ( "" )} </> )} </div> )} </th> ))} </tr> ))} </thead> <tbody className="bg-white"> {table.getRowModel().rows?.length ? ( table .getRowModel() .rows.slice(0, 10) .map((row) => ( <tr key={row.id} className="w-full border-b border-gray-100 last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg" > {row.getVisibleCells().map((cell) => ( <td key={cell.id} className="whitespace-nowrap px-3 py-3" > {flexRender( cell.column.columnDef.cell, cell.getContext() )} </td> ))} </tr> )) ) : ( <tr> <td colSpan={table.getAllColumns().length} className="whitespace-nowrap px-3 py-3 text-center" > No results found </td> </tr> )} </tbody> </table> </div> </div> </div> );
}
Hook useEffect
đảm bảo rằng các tham số tìm kiếm URL được cập nhật dựa trên state sorting, cho phép thành phần duy trì state sorting trong URL.
pagination.tsx
import { Link, useSearchParams } from "react-router-dom";
import { FiMoreHorizontal } from "react-icons/fi";
import { FaAngleLeft, FaAngleRight } from "react-icons/fa";
import { DOTS, usePagination } from "@/hooks/use-pagination"; type PaginationPropsType = { totalCount: number; siblingCount?: number; pageSize: number;
}; /** * Pagination component for handling pagination logic and rendering pagination UI. * @param {PaginationPropsType} props - Pagination props including totalCount, pageSize, and siblingCount. * @returns {JSX.Element | null} Pagination UI elements. */ export function Pagination({ totalCount, pageSize, siblingCount = 1,
}: PaginationPropsType) { const pageParam = "page"; const [queryParams] = useSearchParams(); const currentPage = Number(queryParams.get(pageParam) || 1); const paginationRange = usePagination({ currentPage, totalCount, siblingCount, pageSize, }); // const totalPages = Math.ceil(totalCount / pageSize); const previousQuery = new URLSearchParams(queryParams); previousQuery.set(pageParam, String(currentPage - 1)); const nextQuery = new URLSearchParams(queryParams); nextQuery.set(pageParam, String(currentPage + 1)); const pageChange = new URLSearchParams(queryParams); if (currentPage === 0 || (paginationRange && paginationRange.length < 2)) { return null; } const lastPage = paginationRange && paginationRange[paginationRange.length - 1]; const isPreviousButtonDisabled = currentPage === 1; const isNextButtonDisabled = currentPage >= Number(lastPage); return ( <div className="justify-end py-4 mx-auto flex w-full"> <div className="justify-center items-center gap-x-3 flex"> <Link to={`?${previousQuery.toString()}`} aria-disabled={isPreviousButtonDisabled} className={ isPreviousButtonDisabled ? "pointer-events-none" : "border p-2 rounded-md" } tabIndex={isPreviousButtonDisabled ? -1 : undefined} > <FaAngleLeft /> </Link> {paginationRange && paginationRange.map((pageNumber, index) => { if (pageNumber === DOTS) { return <FiMoreHorizontal className="h-4 w-4" key={index} />; } const isActiveButton = currentPage === pageNumber; pageChange.set(pageParam, String(pageNumber)); return ( <Link key={index} className={`border py-1 px-2.5 rounded-md ${ isActiveButton ? "bg-blue-400 text-white" : "" }`} to={`?${pageChange.toString()}`} > {pageNumber} </Link> ); })} <Link to={`?${nextQuery.toString()}`} aria-disabled={isNextButtonDisabled} className={ isNextButtonDisabled ? "pointer-events-none" : "border p-2 rounded-md" } tabIndex={isNextButtonDisabled ? -1 : undefined} > <FaAngleRight /> </Link> </div> </div> );
}
Hook useSearchParams
được sử dụng để đọc và chỉnh sửa truy vấn chuỗi trong URL cho vị trí hiện tại và Interface URLSearchParams
xác định các phương thức tiện ích để hoạt động với truy vấn chuỗi của URL.
search-input.tsx
import { FC, FormEvent } from "react";
import { debounce } from "@/lib/utils";
import { Form, useSearchParams, useSubmit } from "react-router-dom"; export const DebouncedInput: FC = () => { const submit = useSubmit(); const [searchParams] = useSearchParams(); const debounceSubmit = debounce((form: HTMLFormElement) => submit(form), 300); const handleSubmit = (event: FormEvent<HTMLFormElement>) => debounceSubmit(event.currentTarget); return ( <Form method="get" onChange={handleSubmit}> <input type="search" name="search" placeholder="Search" className="outline-none mt-1 text-gray-500 border-2 rounded-md px-4 py-1.5 duration-200 focus:border-primary-50" defaultValue={searchParams.get("search") || ""} /> </Form> );
};
Ở đây, chúng ta triển khai chức năng tiện ích debounce để điều tiết đầu vào của người dùng, đảm bảo tương tác mượt mà hơn. Ngoài ra, chúng ta tận dụng Form components được cung cấp bởi react-router, được xây dựng dựa trên các biểu mẫu HTML, để quản lý state form và gửi một cách liền mạch. Hãy triển khai giao diện skeleton để cung cấp phản hồi tải hấp dẫn hơn cho người dùng.
skeleton.tsx
export function TableRowSkeleton() { return ( <tr className="w-full border-b border-gray-100 last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg"> {/* Customer Name and Image */} <td className="relative overflow-hidden whitespace-nowrap py-3 pl-6 pr-3"> <div className="flex items-center gap-3"> <div className="h-8 w-8 rounded-full bg-gray-100"></div> <div className="h-6 w-24 rounded bg-gray-100"></div> </div> </td> {/* Email */} <td className="whitespace-nowrap px-3 py-3"> <div className="h-6 w-32 rounded bg-gray-100"></div> </td> {/* Phone Number */} <td className="whitespace-nowrap px-3 py-3"> <div className="h-6 w-16 rounded bg-gray-100"></div> </td> {/* Designation */} <td className="whitespace-nowrap px-3 py-3"> <div className="h-6 w-16 rounded bg-gray-100"></div> </td> </tr> );
} export function UserTableSkeleton() { return ( <div className="mt-6 flow-root"> <div className="inline-block min-w-full align-middle"> <div className="rounded-lg bg-gray-50 p-2 md:pt-0"> <table className="min-w-full text-gray-900"> <thead className="rounded-lg text-left text-sm font-normal"> <tr> <th scope="col" className="px-4 py-5 font-medium sm:pl-6"> Name </th> <th scope="col" className="px-3 py-5 font-medium"> Email </th> <th scope="col" className="px-3 py-5 font-medium"> Phone Number </th> <th scope="col" className="px-3 py-5 font-medium"> Designation </th> </tr> </thead> <tbody className="bg-white"> <TableRowSkeleton /> <TableRowSkeleton /> <TableRowSkeleton /> <TableRowSkeleton /> <TableRowSkeleton /> <TableRowSkeleton /> </tbody> </table> </div> </div> </div> );
}
Bây giờ, hãy thu hẹp khoảng cách giữa frontend và máy chủ backend JSON mô phỏng của chúng ta bằng cách sử dụng react-query
của TanStack’s
user-api-slice.ts
import axios from "axios";
import { UserColumn } from "@/pages/users/partials/use-column"; export async function getAllUsers({ page, search, sortBy, orderBy = "asc",
}: { page: string; search: string; sortBy: string; orderBy: "asc" | "desc";
}): Promise<UserColumn[]> { let userUrl = `${import.meta.env.VITE_MOCK_DATA_URL}/users?`; if (search) { userUrl += `q=${search}&`; } if (page) { userUrl += `_page=${page}&`; } if (sortBy) { userUrl += `_sort=${sortBy}&`; } if (orderBy) { userUrl += `_order=${orderBy}&`; } if (userUrl.endsWith("&")) { userUrl = userUrl.slice(0, -1); } return axios.get(`${userUrl}`).then((response) => response.data);
}
Hàm truy vấn này sử dụng Axios để tìm nạp dữ liệu từ máy chủ. Nó lấy danh sách users dựa trên các tham số được cung cấp như page number, search query, sorting criteria, and order. Hàm xây dựng URL thích hợp để tìm nạp dữ liệu từ máy chủ JSON mô phỏng dựa trên các tham số được cung cấp.
user-api.ts
import { useQuery } from "@tanstack/react-query";
import { getAllUsers } from "@/pages/users/api/user-api-slice";
import { useSearchParams } from "react-router-dom"; export function useGetAllUsers() { const [searchParams] = useSearchParams(); const page = searchParams.get("page") as string; const search = searchParams.get("search") as string; const orderBy = searchParams.get("order_by") as "asc" | "desc"; const sortBy = searchParams.get("sort_by") as string; return useQuery({ queryKey: ["users", search, orderBy, sortBy, page], queryFn: () => getAllUsers({ search, orderBy, sortBy, page }), });
}
Một hook tùy chỉnh có tên useGetAllUsers sử dụng useQuery hook của TanStack để tìm nạp dữ liệu. Các tham số truy vấn trực tiếp đến các từ khóa truy vấn đảm bảo rằng tất cả các thay đổi đối với các tham số đó sẽ tự động kích hoạt tìm nạp lại dữ liệu.
Hãy xây dựng custom hook cung cấp một cách hiệu quả và có thể tái sử dụng để xác định các cột nhằm hiển thị user data trong table component. Nó trừu tượng hóa logic cấu hình column khỏi table Tanstack, giúp việc duy trì và tái sử dụng trên các phần khác nhau của ứng dụng trở nên dễ dàng hơn.
use-column.tsx
import { useMemo } from "react";
import { ColumnDef } from "@tanstack/react-table"; export type UserColumn = { name: string; email: string; imageUrl: string; phoneNumber: string; designation: string;
}; export function useColumn() { const columns = useMemo<ColumnDef<UserColumn>[]>( () => [ { accessorKey: "name", header: "User", cell: (info) => { const { name, imageUrl } = info.row.original; return ( <figure className="flex items-center gap-x-2"> <div className="w-10 h-10 border rounded-full"> <img src={imageUrl} alt="profileimage" className="h-full w-full rounded-full object-cover object-center" /> </div> <figcaption>{name}</figcaption> </figure> ); }, }, { accessorKey: "email", header: "Email", cell: (info) => info.getValue(), }, { accessorKey: "phoneNumber", header: "Phone Number", cell: (info) => info.getValue(), }, { accessorKey: "designation", header: "Designation", cell: (info) => info.getValue(), }, ], [] ); return { columns };
}
Bây giờ hãy render tất cả các table component của chung ta bằng tất cả các thành phần của nó.
users-page.tsx
import { DataTable } from "@/components/data-table";
import { useTable } from "@/hooks/use-table";
import { DebouncedInput } from "@/components/search-input";
import { useGetAllUsers } from "@/pages/users/api/user-api";
import { useColumn } from "@/pages/users/partials/use-column";
import { UserTableSkeleton } from "@/components/skeleton";
import { Pagination } from "@/components/pagination"; export default function UsersPage() { const { columns } = useColumn(); const { data: userList, isLoading, isError } = useGetAllUsers(); const { table, sorting } = useTable(columns, userList!); return ( <> <div className="w-1/4 mb-2"> <DebouncedInput /> </div> {isLoading ? ( <UserTableSkeleton /> ) : isError ? ( <h1>Error</h1> ) : ( <> <DataTable table={table} sorting={sorting} /> {userList && ( <Pagination pageSize={10} totalCount={userList.length} /> )} </> )} </> );
}
Bây giờ, bạn có thể điều hướng tới đây và thao tác với table.
Tuy nhiên, có một thách thức nhỏ khi xác định tổng số lượng phân trang.😅 Thông thường, chúng tôi sẽ truy xuất thông tin này từ phản hồi của máy chủ. Tuy nhiên, vì chúng tôi đang sử dụng Máy chủ JSON nên nó không cung cấp tổng số dựa trên các điều kiện được áp dụng. Tuy nhiên, bạn vẫn có thể sử dụng tổng số lượng do máy chủ cung cấp, đủ cho chức năng phân trang liền mạch.
Tóm lại, chúng tôi đã khám phá việc triển khai phân trang, tìm kiếm và sắp xếp phía máy chủ trong React bằng cách sử dụng react-table
, react-query
, and react-router
. Bất chấp những thách thức như truy xuất tổng số từ máy chủ mô phỏng, chúng tôi đã cung cấp các giải pháp thay thế. Với kiến thức này, bạn đã sẵn sàng tạo các ứng dụng React hiệu suất cao với trải nghiệm người dùng liền mạch.
Để có mã hoàn chỉnh, vui lòng truy cập vào kho lưu trữ.
Cảm ơn bạn đã đọc🤞