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 + keepPreviousData | useInfiniteQuery |
|---|---|---|
| Mẫu UI | Trang rời (1, 2, 3…) | ”Tải thêm” / cuộn vô hạn |
| Cache | Mỗi trang một entry (['list', { page: 1 }], { page: 2 }…) | Mọi trang chung một entry (['list', 'infinite']) |
| Hành vi | Thay thế trang | Tích luỹ trang |
data | Object một trang | { pages, pageParams } |
| Hợp với | Bảng quản trị, kết quả có số trang | Feed, 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 = undefined và status = 'pending', React Query đưa data của observer trước đó vào làm placeholder. Nhờ vậy:
statusgiữ nguyên'success'(không rơi về'pending') →isPending = false.datatrỏ 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ề |
|---|---|---|---|
isPending | true (hiện skeleton) | false | false |
isFetching | true | true | false |
isPlaceholderData | false | true (data là trang cũ) | false |
data | undefined | trang 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.prefetchQuerychopage + 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=2hoặ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 cursor0). Bắt buộc ở v5 (v4 không có; quên là lỗi type).queryFnnhậ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
| Option | Kiểu | Ý nghĩa |
|---|---|---|
queryKey | QueryKey | Danh 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. |
initialPageParam | TPageParam | Bắt buộc (v5). Param của trang đầu tiên. |
getNextPageParam | (lastPage, allPages, lastPageParam, allPageParams) => TPageParam | undefined | null | Suy param trang kế; undefined/null để dừng. |
getPreviousPageParam | (firstPage, allPages, firstPageParam, allPageParams) => TPageParam | undefined | null | Suy param trang trước (infinite hai chiều). |
maxPages | number | Giới hạn số trang giữ trong cache (cửa sổ trượt). |
select | (data) => U | Biến đổi { pages, pageParams } trước khi trả về component. |
staleTime / gcTime | number | Như useQuery — áp cho cả entry (mọi trang). |
enabled | boolean | Bật/tắt query (dependent infinite query). |
placeholderData | keepPreviousData | fn | Giữ data cũ khi đổi key (vd đổi q). |
8. Bảng tra cứu: giá trị trả về của useInfiniteQuery
| Field | Kiểu | Ý nghĩa |
|---|---|---|
data | { pages, pageParams } | undefined | Toàn bộ trang đã tải. |
data.pages | T[] | Mảng kết quả từng trang — flatMap để render. |
data.pageParams | TPageParam[] | 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). |
hasNextPage | boolean | true nếu getNextPageParam trả param khác null/undefined. |
hasPreviousPage | boolean | Tương tự cho chiều ngược. |
isFetchingNextPage | boolean | Đang tải trang kế (spinner ở đáy). |
isFetchingPreviousPage | boolean | Đang tải trang trước (spinner ở đỉnh). |
isFetching | boolean | Đang fetch bất kỳ (gồm refetch nền mọi trang). |
isPending | boolean | Chưa có trang nào (skeleton lần đầu). |
isError / error | boolean / E | Trạ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ắcmaxPages(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ảgetNextPageParamlẫngetPreviousPageParam, 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
useQuerythườ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ống | Hậu quả | Cách đúng |
|---|---|---|
Quên initialPageParam (v5) | Lỗi type / runtime, query không chạy | Luô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ư useQuery | undefined — data là { pages, pageParams } | Dùng data.pages.flatMap(p => p.items). |
keepPreviousData: true (cú pháp v4) | Lỗi type ở v5 | placeholderData: 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 trang | disabled={!hasNextPage || isFetchingNextPage} / chỉ observe khi rảnh. |
maxPages mà thiếu getPreviousPageParam | Không lấy lại được trang đã loại | Khai báo cả hai getNext/getPreviousPageParam. |
Dựa vào refetch cho list 30 trang | 30 request tuần tự, chậm | Câ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' và 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 là { 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
pagevào key +placeholderData: keepPreviousDatagiữ trang cũ trong lúc tải trang mới (giữstatus='success', không skeleton). DùngisPlaceholderDatađể khoá nút điều hướng,isFetchingcho indicator. useInfiniteQuerycầninitialPageParam(bắt buộc v5),queryFn({ pageParam }),getNextPageParam(trảundefined/nullđể dừng).datalà{ pages, pageParams }→flatMapđể render phẳng.- Infinite hai chiều với
getPreviousPageParam/fetchPreviousPage;maxPagesgiớ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 khihasNextPage && !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.