能夠以增量方式「載入更多」資料到現有資料集或實現「無限滾動」的列表渲染,也是一種非常常見的 UI 模式。TanStack Query 提供了一個名為 useInfiniteQuery 的 useQuery 變體,專門用於查詢這類列表。
使用 useInfiniteQuery 時,你會注意到以下幾點不同:
注意:選項 initialData 或 placeholderData 需要符合具有 data.pages 和 data.pageParams 屬性的物件結構。
假設我們有一個 API,基於 cursor 索引每次返回 3 個 projects 的頁面,並提供可用於獲取下一組專案的游標:
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }
根據這些資訊,我們可以通過以下方式建立一個「載入更多」的 UI:
import { useInfiniteQuery } from '@tanstack/react-query'
function Projects() {
const fetchProjects = async ({ pageParam }) => {
const res = await fetch('/api/projects?cursor=' + pageParam)
return res.json()
}
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
return status === 'pending' ? (
<p>載入中...</p>
) : status === 'error' ? (
<p>錯誤: {error.message}</p>
) : (
<>
{data.pages.map((group, i) => (
<React.Fragment key={i}>
{group.data.map((project) => (
<p key={project.id}>{project.name}</p>
))}
</React.Fragment>
))}
<div>
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? '載入更多中...'
: hasNextPage
? '載入更多'
: '已無更多內容'}
</button>
</div>
<div>{isFetching && !isFetchingNextPage ? '獲取中...' : null}</div>
</>
)
}
import { useInfiniteQuery } from '@tanstack/react-query'
function Projects() {
const fetchProjects = async ({ pageParam }) => {
const res = await fetch('/api/projects?cursor=' + pageParam)
return res.json()
}
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
return status === 'pending' ? (
<p>載入中...</p>
) : status === 'error' ? (
<p>錯誤: {error.message}</p>
) : (
<>
{data.pages.map((group, i) => (
<React.Fragment key={i}>
{group.data.map((project) => (
<p key={project.id}>{project.name}</p>
))}
</React.Fragment>
))}
<div>
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? '載入更多中...'
: hasNextPage
? '載入更多'
: '已無更多內容'}
</button>
</div>
<div>{isFetching && !isFetchingNextPage ? '獲取中...' : null}</div>
</>
)
}
必須理解的是,在進行中的獲取過程中呼叫 fetchNextPage 有可能會覆蓋正在背景進行的資料刷新。這種情況在渲染列表並同時觸發 fetchNextPage 時尤其關鍵。
請記住,對於 InfiniteQuery 只能有一個進行中的獲取。所有頁面共享單一快取條目,嘗試同時進行兩次獲取可能會導致資料覆寫。
如果你想啟用同時獲取,可以在 fetchNextPage 中使用 { cancelRefetch: false } 選項(預設為 true)。
為了確保查詢過程順暢無衝突,強烈建議檢查查詢是否處於 isFetching 狀態,特別是當使用者不會直接控制該呼叫時。
<List onEndReached={() => !isFetchingNextPage && fetchNextPage()} />
<List onEndReached={() => !isFetchingNextPage && fetchNextPage()} />
當無限查詢變為 stale 並需要重新獲取時,每組資料會從第一組開始「依序」獲取。這確保即使基礎資料發生變更,我們也不會使用過期的游標,從而避免獲取重複或跳過記錄。如果無限查詢的結果從 queryCache 中被移除,分頁將從初始狀態重新開始,僅請求初始組。
雙向列表可以通過使用 getPreviousPageParam、fetchPreviousPage、hasPreviousPage 和 isFetchingPreviousPage 屬性和函數來實現。
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})
有時你可能希望以反向順序顯示頁面。如果是這種情況,可以使用 select 選項:
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
select: (data) => ({
pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(),
}),
})
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
select: (data) => ({
pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(),
}),
})
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1),
}))
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1),
}))
const newPagesArray =
oldPagesArray?.pages.map((page) =>
page.filter((val) => val.id !== updatedId),
) ?? []
queryClient.setQueryData(['projects'], (data) => ({
pages: newPagesArray,
pageParams: data.pageParams,
}))
const newPagesArray =
oldPagesArray?.pages.map((page) =>
page.filter((val) => val.id !== updatedId),
) ?? []
queryClient.setQueryData(['projects'], (data) => ({
pages: newPagesArray,
pageParams: data.pageParams,
}))
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(0, 1),
pageParams: data.pageParams.slice(0, 1),
}))
queryClient.setQueryData(['projects'], (data) => ({
pages: data.pages.slice(0, 1),
pageParams: data.pageParams.slice(0, 1),
}))
確保始終保持 pages 和 pageParams 的相同資料結構!
在某些使用場景中,你可能希望限制查詢資料中儲存的頁面數量,以提高效能和使用者體驗:
解決方案是使用「有限無限查詢」。這可以通過將 maxPages 選項與 getNextPageParam 和 getPreviousPageParam 結合使用來實現,以便在需要時雙向獲取頁面。
在以下範例中,查詢資料 pages 陣列中僅保留 3 頁。如果需要重新獲取,僅會依序重新獲取 3 頁。
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
maxPages: 3,
})
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
maxPages: 3,
})
如果你的 API 沒有返回游標,可以將 pageParam 用作游標。因為 getNextPageParam 和 getPreviousPageParam 也會獲取當前頁面的 pageParam,你可以用它來計算下一頁/上一頁的參數。
return useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (lastPage.length === 0) {
return undefined
}
return lastPageParam + 1
},
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
if (firstPageParam <= 1) {
return undefined
}
return firstPageParam - 1
},
})
return useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (lastPage.length === 0) {
return undefined
}
return lastPageParam + 1
},
getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
if (firstPageParam <= 1) {
return undefined
}
return firstPageParam - 1
},
})