jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

TanStack Query · Phần 4 — Pagination & useInfiniteQuery

Chuyển trang mượt không nhấp nháy với placeholderData keepPreviousData, rồi dựng infinite scroll thực thụ bằng useInfiniteQuery với getNextPageParam, fetchNextPage và IntersectionObserver.

Danh sách dài là chỗ React Query toả sáng. Phần này giải quyết hai mẫu UI phổ biến nhất: phân trang (pagination) kiểu “Trang trước / Trang sau”, và cuộn vô hạn (infinite scroll) kiểu “tải thêm khi cuộn tới đáy”. Cả hai đều có cạm bẫy UX (nhấp nháy, mất vị trí cuộn) mà React Query xử lý gọn.

Ta tiếp tục dùng queryOptions, apiFetch + zod từ Phần 3.


1. Hai mẫu UI, hai công cụ

Danh sách dài có hai cách hiển thị, và React Query có hai công cụ tương ứng. Đừng nhầm lẫn — chọn sai sẽ phải tự gộp mảng, tự đếm trang và mất hết cờ tiện ích.

PAGINATION (trang rời)              INFINITE (cuộn vô hạn / tải thêm)
┌─────────────────┐                 ┌─────────────────┐
│  Trang 2 / 47   │                 │  item 1         │
│  item 21        │                 │  item 2         │
│  item 22        │                 │  ...            │
│  ...            │                 │  item 60        │
│  item 40        │                 │  ↓ tải thêm…    │  ← nối thêm trang mới
│  [‹ Trước] [Sau ›]                │  (item 61…80)   │     vào CUỐI danh sách
└─────────────────┘                 └─────────────────┘
 thay TRANG cũ bằng TRANG mới        TÍCH LUỸ trang vào một danh sách
 → useQuery + keepPreviousData       → useInfiniteQuery
Tiêu chíuseQuery + keepPreviousDatauseInfiniteQuery
Mẫu UITrang rời (1, 2, 3…)”Tải thêm” / cuộn vô hạn
CacheMỗi trang một entry (['list', { page: 1 }], { page: 2 }…)Mọi trang chung một entry (['list', 'infinite'])
Hành viThay thế trangTích luỹ trang
dataObject một trang{ pages, pageParams }
Hợp vớiBảng quản trị, kết quả có số trangFeed, timeline, gallery, search

Quy tắc nhanh: nếu người dùng có thể “nhảy tới trang 7” → pagination. Nếu chỉ “xem tiếp xuống dưới” → infinite.


2. Pagination ngây thơ và vấn đề nhấp nháy

Cách đơn giản nhất là cho page vào query key:

function CustomersPage() {
  const [page, setPage] = useState(1);

  const { data, isPending, isError } = useQuery({
    queryKey: ['customers', { page }],
    queryFn: () => apiFetch(`/customers?page=${page}`, pageSchema),
  });

  if (isPending) return <TableSkeleton />; // ❌ nhấp nháy mỗi lần đổi trang
  // ...
}

Vấn đề: mỗi page là một cache entry khác. Khi bấm “Trang sau”, key đổi từ { page: 1 } sang { page: 2 }, query mới chưa có cache → status về 'pending'isPending bật → bảng biến thành skeleton → nhấp nháy và nhảy layout. Trải nghiệm rất tệ với bảng dữ liệu, nhất là khi bảng cao.

Bấm "Sau":  data(page1) ──► (key đổi) ──► undefined ──► data(page2)
            bảng hiện     │              SKELETON      bảng hiện
                          └─ isPending = true ─┘  ← khoảng trống nhấp nháy

3. placeholderData: keepPreviousData — giữ trang cũ khi tải trang mới

Tài liệu: Paginated Queries.

Giải pháp là giữ data của trang trước hiển thị trong lúc trang mới đang fetch nền. React Query v5 có helper keepPreviousData (v4 viết keepPreviousData: true — đã đổi cú pháp, xem Gotchas):

import { useQuery, keepPreviousData } from '@tanstack/react-query';

function CustomersPage() {
  const [page, setPage] = useState(1);

  const { data, isPlaceholderData, isFetching, isPending, isError } = useQuery({
    queryKey: ['customers', { page }],
    queryFn: () => apiFetch(`/customers?page=${page}`, pageSchema),
    placeholderData: keepPreviousData, // ← giữ data trang trước trong lúc fetch trang mới
  });

  if (isPending) return <TableSkeleton />; // chỉ chạy đúng lần đầu tiên
  if (isError) return <ErrorState />;

  return (
    <div>
      {/* data luôn có nội dung (trang trước hoặc trang mới) → KHÔNG nhấp nháy */}
      <CustomerTable rows={data.items} style={{ opacity: isPlaceholderData ? 0.6 : 1 }} />

      <div className="flex items-center gap-2">
        <button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
          Trang trước
        </button>
        <span>Trang {page}</span>
        <button
          onClick={() => setPage((p) => p + 1)}
          // isPlaceholderData = true nghĩa là data hiện tại là của trang CŨ,
          // trang mới chưa về → khoá nút "Sau" để tránh nhảy 2 trang.
          disabled={isPlaceholderData || !data.hasMore}
        >
          Trang sau
        </button>
        {isFetching ? <span>Đang tải…</span> : null}
      </div>
    </div>
  );
}

Cơ chế bên dưới (vì sao không về pending)

keepPreviousData là một hàm placeholder đặc biệt: (previousData) => previousData. Khi query key đổi mà chưa có cache, thay vì để data = undefinedstatus = 'pending', React Query đưa data của observer trước đó vào làm placeholder. Nhờ vậy:

  • status giữ nguyên 'success' (không rơi về 'pending') → isPending = false.
  • data trỏ tới dữ liệu trang cũ → component vẫn render được bảng.
  • fetchStatus = 'fetching'isFetching = true (đang fetch trang mới nền).
  • isPlaceholderData = true để bạn biết “đây là data cũ, đừng tin các nút điều hướng”.
            page 1 (đã cache)        page 2 (đang fetch)         page 2 (xong)
            ─────────────────        ───────────────────         ─────────────
data        = trang 1                = trang 1  ◄── CŨ!          = trang 2
status      = 'success'              = 'success'                 = 'success'
isPending   = false                  = false   ◄── KHÔNG skeleton = false
isFetching  = false                  = true                      = false
isPlaceholderData = false            = true                      = false

Bảng cờ trạng thái khi chuyển trang

CờLần đầu (chưa cache)Đang đổi trang (có keepPreviousData)Sau khi trang mới về
isPendingtrue (hiện skeleton)falsefalse
isFetchingtruetruefalse
isPlaceholderDatafalsetrue (data là trang cũ)false
dataundefinedtrang cũtrang mới

Quy tắc dùng: isPending cho skeleton lần đầu, isFetching cho indicator nhỏ “đang tải”, isPlaceholderData để khoá nút điều hướng + làm mờ bảng nhẹ.

Prefetch trang kế tiếp để cảm giác tức thì hơn nữa: khi trang hiện tại load xong, gọi queryClient.prefetchQuery cho page + 1 để trang sau đã nằm sẵn trong cache trước cả khi người dùng bấm. Ta đào sâu prefetch ở Phần 11.


4. Schema cho response phân trang (offset vs cursor)

Response phân trang thường kèm metadata điều hướng. Có hai kiểu phổ biến:

  • Offset/page — client gửi ?page=2 hoặc ?offset=40&limit=20. Server biết tổng số, trả totalPages. Hợp với UI số trang.
  • Cursor — server trả nextCursor (id/timestamp của phần tử cuối); client gửi lại để lấy trang kế. Ổn định khi data thay đổi giữa chừng, hợp với infinite.

Validate cả phần metadata bằng zod tại biên (xem Phần 3):

// src/features/customers/schema.ts
import { z } from 'zod';

export const customerSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

// Kiểu cursor (dùng cho cả pagination lẫn infinite ở bài này)
export const pageSchema = z.object({
  items: z.array(customerSchema),
  nextCursor: z.number().nullable(), // null = hết trang
  hasMore: z.boolean(),
});

export type CustomerPage = z.infer<typeof pageSchema>;

5. Recipe: UI phân trang có số trang

Ngoài “Trước/Sau”, bảng quản trị thường có dãy số trang. Kết hợp tổng số trang từ server với keepPreviousData:

function PaginatedCustomers() {
  const [page, setPage] = useState(1);

  const { data, isPending, isError, isPlaceholderData } = useQuery({
    queryKey: ['customers', { page }],
    queryFn: () => apiFetch(`/customers?page=${page}`, pageMetaSchema),
    placeholderData: keepPreviousData,
  });

  if (isPending) return <TableSkeleton />;
  if (isError) return <ErrorState />;

  const { items, totalPages } = data;

  return (
    <div>
      <CustomerTable rows={items} dimmed={isPlaceholderData} />

      <nav className="flex gap-1" aria-label="Phân trang">
        <button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>‹</button>

        {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
          <button
            key={n}
            onClick={() => setPage(n)}
            aria-current={n === page ? 'page' : undefined}
            className={n === page ? 'font-bold' : ''}
            // Khoá cả dãy khi đang fetch trang mới để tránh bấm dồn.
            disabled={isPlaceholderData}
          >
            {n}
          </button>
        ))}

        <button
          onClick={() => setPage((p) => p + 1)}
          disabled={isPlaceholderData || page === totalPages}
        >›</button>
      </nav>
    </div>
  );
}

pageMetaSchema thêm totalPages: z.number() so với pageSchema. Vì mỗi trang là một cache entry riêng, khi quay lại trang đã xem, data hiện tức thì từ cache (chỉ refetch nền nếu đã stale).


6. useInfiniteQuery — giải phẫu đầy đủ

Tài liệu: Infinite Queries.

Pagination thay thế trang cũ bằng trang mới. Infinite query thì nối thêm trang mới vào danh sách đang có. useInfiniteQuery quản lý một mảng các “page” trong cùng một cache entry.

Ba mảnh ghép bắt buộc

  • initialPageParam — tham số trang đầu tiên (vd cursor 0). Bắt buộc ở v5 (v4 không có; quên là lỗi type).
  • queryFn nhận { pageParam } để biết đang tải trang nào.
  • getNextPageParam — từ trang cuối, suy ra param cho trang kế; trả undefined/null để dừng.
// src/features/customers/api.ts
import { infiniteQueryOptions } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api-client';
import { pageSchema } from './schema';

export function customersInfiniteQuery(q: string = '') {
  return infiniteQueryOptions({
    queryKey: ['customers', 'infinite', { q }], // q nằm trong key → đổi filter là refetch
    queryFn: ({ pageParam }) =>
      apiFetch(`/customers?cursor=${pageParam}&q=${q}`, pageSchema),
    initialPageParam: 0,
    // Lấy cursor của trang mới nhất để biết trang kế. null/undefined → hết, dừng fetch.
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });
}

Chữ ký đầy đủ của getNextPageParam có 4 tham số, hữu ích khi cursor cần suy từ toàn bộ lịch sử:

getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
  if (!lastPage.hasMore) return undefined;          // dừng
  return lastPageParam + lastPage.items.length;     // offset kiểu thủ công
}

Cấu trúc data.pages / data.pageParams

Khác useQuery, data ở đây là { pages, pageParams } — hai mảng song song:

useInfiniteQuery → data

├─ data.pageParams : [ 0,   20,  40 ]    ← param đã dùng cho từng trang
│                      │    │    │
│                      ▼    ▼    ▼
└─ data.pages      : [ P0,  P1,  P2 ]    ← kết quả queryFn của từng trang
                       │    │    │           (mỗi Pn = { items, nextCursor, hasMore })
                       ▼    ▼    ▼
   data.pages.flatMap(p => p.items)
                       └────┴────┘
   → [ item0…19, item20…39, item40…59 ]  ← danh sách PHẲNG để render

pageParams[i] là param đã dùng để tải pages[i]. Khi gọi fetchNextPage(), React Query lấy getNextPageParam(...) → đẩy param mới vào pageParams, kết quả mới vào cuối pages.

Render bằng flatMap

import { useInfiniteQuery } from '@tanstack/react-query';
import { customersInfiniteQuery } from './api';

function CustomerFeed({ q }: { q: string }) {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isPending,
    isError,
  } = useInfiniteQuery(customersInfiniteQuery(q));

  if (isPending) return <FeedSkeleton />;
  if (isError) return <ErrorState />;

  // data.pages là MẢNG các trang; gộp items lại để render phẳng.
  const customers = data.pages.flatMap((page) => page.items);

  return (
    <div>
      <ul>
        {customers.map((c) => (
          <li key={c.id}>{c.name}</li>
        ))}
      </ul>

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Đang tải…' : hasNextPage ? 'Tải thêm' : 'Hết rồi'}
      </button>
    </div>
  );
}

7. Bảng tra cứu: options của useInfiniteQuery

OptionKiểuÝ nghĩa
queryKeyQueryKeyDanh tính cache — nhớ gồm cả filter (q, sort…).
queryFn({ pageParam, signal }) => Promise<T>Tải một trang; pageParam cho biết trang nào.
initialPageParamTPageParamBắt buộc (v5). Param của trang đầu tiên.
getNextPageParam(lastPage, allPages, lastPageParam, allPageParams) => TPageParam | undefined | nullSuy param trang kế; undefined/null để dừng.
getPreviousPageParam(firstPage, allPages, firstPageParam, allPageParams) => TPageParam | undefined | nullSuy param trang trước (infinite hai chiều).
maxPagesnumberGiới hạn số trang giữ trong cache (cửa sổ trượt).
select(data) => UBiến đổi { pages, pageParams } trước khi trả về component.
staleTime / gcTimenumberNhư useQuery — áp cho cả entry (mọi trang).
enabledbooleanBật/tắt query (dependent infinite query).
placeholderDatakeepPreviousData | fnGiữ data cũ khi đổi key (vd đổi q).

8. Bảng tra cứu: giá trị trả về của useInfiniteQuery

FieldKiểuÝ nghĩa
data{ pages, pageParams } | undefinedToàn bộ trang đã tải.
data.pagesT[]Mảng kết quả từng trang — flatMap để render.
data.pageParamsTPageParam[]Param tương ứng từng trang.
fetchNextPage() => Promise<…>Tải trang kế (nối vào cuối).
fetchPreviousPage() => Promise<…>Tải trang trước (chèn vào đầu).
hasNextPagebooleantrue nếu getNextPageParam trả param khác null/undefined.
hasPreviousPagebooleanTương tự cho chiều ngược.
isFetchingNextPagebooleanĐang tải trang kế (spinner ở đáy).
isFetchingPreviousPagebooleanĐang tải trang trước (spinner ở đỉnh).
isFetchingbooleanĐang fetch bất kỳ (gồm refetch nền mọi trang).
isPendingbooleanChưa có trang nào (skeleton lần đầu).
isError / errorboolean / ETrạng thái lỗi.
refetch() => Promise<…>Refetch lại toàn bộ trang đã tải, theo thứ tự.

Lưu ý refetch: nó tải lại tất cả trang đang có (tuần tự) để giữ tính nhất quán, không chỉ trang đầu. Danh sách 30 trang → 30 request. Cân nhắc maxPages (mục 10).


9. Infinite hai chiều (bidirectional)

Khi cần cuộn cả hai phía — vd chat (cuộn lên lịch sử, cuộn xuống tin mới) hay lịch theo tuần — thêm getPreviousPageParam:

infiniteQueryOptions({
  queryKey: ['messages', { room }],
  queryFn: ({ pageParam }) =>
    apiFetch(`/rooms/${room}/messages?cursor=${pageParam}`, messagePageSchema),
  initialPageParam: 0,
  getNextPageParam: (last) => last.nextCursor ?? undefined,      // tin cũ hơn ↓
  getPreviousPageParam: (first) => first.prevCursor ?? undefined, // tin mới hơn ↑
});
const {
  data, fetchNextPage, fetchPreviousPage,
  hasNextPage, hasPreviousPage,
  isFetchingNextPage, isFetchingPreviousPage,
} = useInfiniteQuery(messagesInfiniteQuery(room));

// Cuộn lên đỉnh → tải tin cũ hơn; cuộn xuống đáy → tải tin mới hơn.

fetchPreviousPage chèn trang vào đầu data.pages, fetchNextPage nối vào cuối. Với chat ngược thời gian, hãy giữ vị trí scroll khi prepend (đo scrollHeight trước/sau) để không nhảy.


10. maxPages — cửa sổ trượt giới hạn bộ nhớ

Cuộn vô hạn lâu → data.pages phình to, tốn RAM và refetch đắt. maxPages giữ tối đa N trang gần nhất; vượt quá thì bỏ trang ở đầu kia:

infiniteQueryOptions({
  queryKey: ['feed'],
  queryFn: ({ pageParam }) => apiFetch(`/feed?cursor=${pageParam}`, pageSchema),
  initialPageParam: 0,
  getNextPageParam: (last) => last.nextCursor ?? undefined,
  getPreviousPageParam: (first) => first.prevCursor ?? undefined, // cần cho cả 2 chiều
  maxPages: 5, // chỉ giữ 5 trang; fetchNextPage trang thứ 6 sẽ loại trang đầu
});
maxPages = 3, đang giữ [P2, P3, P4]
fetchNextPage() →  [P3, P4, P5]   ← P2 bị loại khỏi đầu
fetchPreviousPage() → [P1, P2, P3] ← P4 bị loại khỏi cuối

Bắt buộc khi dùng maxPages: phải khai báo cả getNextPageParam lẫn getPreviousPageParam, vì sau khi loại bớt trang, React Query cần biết cách lấy lại trang ở chiều ngược.


11. Recipe: infinite scroll bằng IntersectionObserver

Thay nút “Tải thêm” bằng “sentinel” ở cuối danh sách: khi nó lọt viewport, gọi fetchNextPage. Tách thành hook tái dùng (logic tách khỏi UI):

// src/hooks/useIntersectionObserver.ts
import { useEffect, useRef } from 'react';

export function useIntersectionObserver(onIntersect: () => void, enabled: boolean) {
  const ref = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const node = ref.current;
    if (!node || !enabled) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) onIntersect();
      },
      { rootMargin: '200px' }, // kích hoạt sớm 200px trước khi tới đáy
    );

    observer.observe(node);
    return () => observer.disconnect(); // dọn observer khi unmount/đổi deps
  }, [onIntersect, enabled]);

  return ref;
}
function CustomerFeed({ q }: { q: string }) {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =
    useInfiniteQuery(customersInfiniteQuery(q));

  // Chỉ quan sát khi còn trang và không đang tải, tránh gọi chồng.
  const sentinelRef = useIntersectionObserver(
    fetchNextPage,
    hasNextPage && !isFetchingNextPage,
  );

  if (isPending) return <FeedSkeleton />;
  const customers = data.pages.flatMap((p) => p.items);

  return (
    <div>
      <ul>{customers.map((c) => <li key={c.id}>{c.name}</li>)}</ul>
      {isFetchingNextPage ? <Spinner /> : null}
      {/* sentinel: lọt viewport → fetchNextPage */}
      <div ref={sentinelRef} aria-hidden />
    </div>
  );
}

Lưu ý hiệu năng: infinite list dài làm nặng DOM. Khi danh sách lớn (hàng nghìn dòng), kết hợp virtualization (vd @tanstack/react-virtual) để chỉ render phần thấy được. React Query lo dữ liệu, virtualizer lo DOM. Đào sâu ở Phần 16.


12. useQuery hay useInfiniteQuery? Chọn sao cho đúng

Bảng ở mục 1 tóm tắt rồi; điểm cốt lõi cần nhớ:

  • Đừng nhồi infinite scroll vào useQuery thường — sẽ phải tự gộp mảng và mất các cờ hasNextPage/fetchNextPage/isFetchingNextPage.
  • Đừng nhồi pagination số-trang vào useInfiniteQuery — nó tích luỹ trang, không hợp với “nhảy tới trang 7”.
  • Cần cả hai trong một màn? Bình thường: bảng quản trị dùng pagination, feed dùng infinite — chúng là hai cache entry độc lập.

13. Gotchas thường gặp

Tình huốngHậu quảCách đúng
Quên initialPageParam (v5)Lỗi type / runtime, query không chạyLuôn khai báo initialPageParam — bắt buộc ở v5.
getNextPageParam trả false/0 để “dừng”Không dừng (chỉ undefined/null mới dừng); 0 là param hợp lệTrả undefined hoặc null khi hết.
Key không gồm filter (q, sort)Đổi filter không refetch, hoặc trộn data filter cũ vào trang mớiĐưa mọi phụ thuộc của queryFn vào queryKey.
Đọc data.items như useQueryundefineddata{ pages, pageParams }Dùng data.pages.flatMap(p => p.items).
keepPreviousData: true (cú pháp v4)Lỗi type ở v5placeholderData: keepPreviousData (import helper, truyền giá trị).
Gọi fetchNextPage() không kiểm tra cờGọi chồng nhiều request, trùng trangdisabled={!hasNextPage || isFetchingNextPage} / chỉ observe khi rảnh.
maxPages mà thiếu getPreviousPageParamKhông lấy lại được trang đã loạiKhai báo cả hai getNext/getPreviousPageParam.
Dựa vào refetch cho list 30 trang30 request tuần tự, chậmCân nhắc maxPages, hoặc invalidate có chủ đích (Phần 5).

14. Bài tập

1. Vì sao pagination ngây thơ (cho page vào key, dựa vào isPending) gây nhấp nháy, và keepPreviousData sửa nó thế nào?

Lời giải

Mỗi page là cache entry riêng; đổi trang → key mới chưa có cache → status về 'pending' → bảng thành skeleton (nhấp nháy). placeholderData: keepPreviousData đưa data trang trước vào làm placeholder, giữ status = 'success'data có nội dung trong khi trang mới fetch nền, nên không bao giờ về trạng thái rỗng giữa các trang.

2. data của useInfiniteQuery có cấu trúc gì? Làm sao render thành một danh sách phẳng?

Lời giải

data{ pages, pageParams }, với data.pages là mảng kết quả từng trang và data.pageParams là mảng param tương ứng. Dùng data.pages.flatMap((p) => p.items) để gộp thành danh sách phẳng.

3. getNextPageParam nên trả về gì khi đã hết dữ liệu, và điều đó ảnh hưởng hasNextPage ra sao?

Lời giải

Trả undefined (hoặc null). Khi đó hasNextPage thành false, fetchNextPage không làm gì nữa — dùng để khoá nút “Tải thêm” / ngừng observer. Lưu ý 0 hay false không dừng vì chúng là param hợp lệ.

4. Phân biệt isPending, isFetching, và isPlaceholderData trong ngữ cảnh chuyển trang với keepPreviousData. Cờ nào dùng cho skeleton, cờ nào để khoá nút “Trang sau”?

Lời giải

isPending = chưa có data nào (chỉ true lần đầu) → dùng cho skeleton. isFetching = đang fetch nền (kể cả khi đã có data cũ) → dùng cho indicator nhỏ “đang tải”. isPlaceholderData = data hiện tại là của trang trước → dùng để khoá nút “Trang sau” và làm mờ bảng. Khi đổi trang có keepPreviousData: isPending=false, isFetching=true, isPlaceholderData=true.

5. Khi nào cần maxPages, và vì sao bật maxPages lại buộc phải khai báo cả getPreviousPageParam?

Lời giải

Cần maxPages khi cuộn vô hạn dài làm data.pages phình to (tốn RAM, refetch đắt). maxPages giữ N trang gần nhất, loại bớt trang ở đầu kia. Vì sau khi loại, người dùng có thể cuộn ngược lại → React Query cần getPreviousPageParam (và getNextPageParam) để tải lại trang đã bỏ ở cả hai chiều.

Nâng cao: Dựng infinite feed hai chiều (chat) với getNextPageParam + getPreviousPageParam, maxPages: 5, auto-load bằng IntersectionObserver cho cả đỉnh lẫn đáy, rồi virtualize bằng @tanstack/react-virtual để giữ mượt ở 10.000 dòng.


Tóm tắt

  • Hai mẫu, hai công cụ: trang rời → useQuery + keepPreviousData; cuộn vô hạn → useInfiniteQuery. Chọn sai sẽ phải tự gộp mảng và mất cờ tiện ích.
  • Pagination: cho page vào key + placeholderData: keepPreviousData giữ trang cũ trong lúc tải trang mới (giữ status='success', không skeleton). Dùng isPlaceholderData để khoá nút điều hướng, isFetching cho indicator.
  • useInfiniteQuery cần initialPageParam (bắt buộc v5), queryFn({ pageParam }), getNextPageParam (trả undefined/null để dừng). data{ pages, pageParams }flatMap để render phẳng.
  • Infinite hai chiều với getPreviousPageParam/fetchPreviousPage; maxPages giới hạn bộ nhớ (buộc khai báo cả hai chiều).
  • Auto-load bằng IntersectionObserver (tách thành hook), chỉ observe khi hasNextPage && !isFetchingNextPage. Danh sách rất dài → thêm virtualization; Query lo dữ liệu, virtualizer lo DOM.

Phần tiếp theo

Phần 5 — Mutations & invalidation: ghi dữ liệu (tạo/sửa/xoá) bằng useMutation, hiểu vòng đời onMutate/onSuccess/onError/onSettled, và sau khi ghi xong thì invalidate đúng query để UI tự đồng bộ với server.