Skip to content

Commit 13e82d6

Browse files
KyleAMathewssamwillis
authored andcommitted
feat: implement useLiveInfiniteQuery hook for React
1 parent 1c54b1b commit 13e82d6

3 files changed

Lines changed: 919 additions & 0 deletions

File tree

packages/react-db/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Re-export all public APIs
22
export * from "./useLiveQuery"
3+
export * from "./useLiveInfiniteQuery"
34

45
// Re-export everything from @tanstack/db
56
export * from "@tanstack/db"
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2+
import { useLiveQuery } from "./useLiveQuery"
3+
import type {
4+
Context,
5+
InferResultType,
6+
InitialQueryBuilder,
7+
QueryBuilder,
8+
} from "@tanstack/db"
9+
10+
export type UseLiveInfiniteQueryConfig<TContext extends Context> = {
11+
pageSize?: number
12+
initialPageParam?: number
13+
getNextPageParam: (
14+
lastPage: Array<InferResultType<TContext>[number]>,
15+
allPages: Array<Array<InferResultType<TContext>[number]>>,
16+
lastPageParam: number,
17+
allPageParams: Array<number>
18+
) => number | undefined
19+
}
20+
21+
export type UseLiveInfiniteQueryReturn<TContext extends Context> = {
22+
data: InferResultType<TContext>
23+
pages: Array<Array<InferResultType<TContext>[number]>>
24+
pageParams: Array<number>
25+
fetchNextPage: () => void
26+
hasNextPage: boolean
27+
isFetchingNextPage: boolean
28+
// From useLiveQuery
29+
state: ReturnType<typeof useLiveQuery<TContext>>[`state`]
30+
collection: ReturnType<typeof useLiveQuery<TContext>>[`collection`]
31+
status: ReturnType<typeof useLiveQuery<TContext>>[`status`]
32+
isLoading: ReturnType<typeof useLiveQuery<TContext>>[`isLoading`]
33+
isReady: ReturnType<typeof useLiveQuery<TContext>>[`isReady`]
34+
isIdle: ReturnType<typeof useLiveQuery<TContext>>[`isIdle`]
35+
isError: ReturnType<typeof useLiveQuery<TContext>>[`isError`]
36+
isCleanedUp: ReturnType<typeof useLiveQuery<TContext>>[`isCleanedUp`]
37+
isEnabled: ReturnType<typeof useLiveQuery<TContext>>[`isEnabled`]
38+
}
39+
40+
/**
41+
* Create an infinite query using a query function with live updates
42+
*
43+
* Phase 1 implementation: Operates within the collection's current dataset.
44+
* Fetching "next page" loads more data from the collection, not from a backend.
45+
*
46+
* @param queryFn - Query function that defines what data to fetch
47+
* @param config - Configuration including pageSize and getNextPageParam
48+
* @param deps - Array of dependencies that trigger query re-execution when changed
49+
* @returns Object with pages, data, and pagination controls
50+
*
51+
* @example
52+
* // Basic infinite query
53+
* const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
54+
* (q) => q
55+
* .from({ posts: postsCollection })
56+
* .orderBy(({ posts }) => posts.createdAt, 'desc')
57+
* .select(({ posts }) => ({
58+
* id: posts.id,
59+
* title: posts.title
60+
* })),
61+
* {
62+
* pageSize: 20,
63+
* getNextPageParam: (lastPage, allPages) =>
64+
* lastPage.length === 20 ? allPages.length : undefined
65+
* }
66+
* )
67+
*
68+
* @example
69+
* // With dependencies
70+
* const { pages, fetchNextPage } = useLiveInfiniteQuery(
71+
* (q) => q
72+
* .from({ posts: postsCollection })
73+
* .where(({ posts }) => eq(posts.category, category))
74+
* .orderBy(({ posts }) => posts.createdAt, 'desc'),
75+
* {
76+
* pageSize: 10,
77+
* getNextPageParam: (lastPage) =>
78+
* lastPage.length === 10 ? lastPage.length : undefined
79+
* },
80+
* [category]
81+
* )
82+
*/
83+
export function useLiveInfiniteQuery<TContext extends Context>(
84+
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
85+
config: UseLiveInfiniteQueryConfig<TContext>,
86+
deps: Array<unknown> = []
87+
): UseLiveInfiniteQueryReturn<TContext> {
88+
const pageSize = config.pageSize || 20
89+
const initialPageParam = config.initialPageParam ?? 0
90+
91+
// Track how many pages have been loaded
92+
const [loadedPageCount, setLoadedPageCount] = useState(1)
93+
const isFetchingRef = useRef(false)
94+
95+
// Stringify deps for comparison
96+
const depsKey = JSON.stringify(deps)
97+
const prevDepsKeyRef = useRef(depsKey)
98+
99+
// Reset page count when dependencies change
100+
useEffect(() => {
101+
if (prevDepsKeyRef.current !== depsKey) {
102+
setLoadedPageCount(1)
103+
prevDepsKeyRef.current = depsKey
104+
}
105+
}, [depsKey])
106+
107+
// Create a live query without limit - fetch all matching data
108+
// Phase 1: Client-side slicing is acceptable
109+
// Phase 2: Will add limit optimization with dynamic adjustment
110+
const queryResult = useLiveQuery((q) => queryFn(q), deps)
111+
112+
// Split the flat data array into pages
113+
const pages = useMemo(() => {
114+
const result: Array<Array<InferResultType<TContext>[number]>> = []
115+
const dataArray = queryResult.data as InferResultType<TContext>
116+
117+
for (let i = 0; i < loadedPageCount; i++) {
118+
const pageData = dataArray.slice(i * pageSize, (i + 1) * pageSize)
119+
result.push(pageData)
120+
}
121+
122+
return result
123+
}, [queryResult.data, loadedPageCount, pageSize])
124+
125+
// Track page params used (for TanStack Query API compatibility)
126+
const pageParams = useMemo(() => {
127+
const params: Array<number> = []
128+
for (let i = 0; i < pages.length; i++) {
129+
params.push(initialPageParam + i)
130+
}
131+
return params
132+
}, [pages.length, initialPageParam])
133+
134+
// Determine if there are more pages available
135+
const hasNextPage = useMemo(() => {
136+
if (pages.length === 0) return false
137+
138+
const lastPage = pages[pages.length - 1]
139+
const lastPageParam = pageParams[pageParams.length - 1]
140+
141+
// Ensure lastPage and lastPageParam are defined before calling getNextPageParam
142+
if (!lastPage || lastPageParam === undefined) return false
143+
144+
// Call user's getNextPageParam to determine if there's more
145+
const nextParam = config.getNextPageParam(
146+
lastPage,
147+
pages,
148+
lastPageParam,
149+
pageParams
150+
)
151+
152+
return nextParam !== undefined
153+
}, [pages, pageParams, config])
154+
155+
// Fetch next page
156+
const fetchNextPage = useCallback(() => {
157+
if (!hasNextPage || isFetchingRef.current) return
158+
159+
isFetchingRef.current = true
160+
setLoadedPageCount((prev) => prev + 1)
161+
162+
// Reset fetching state synchronously
163+
Promise.resolve().then(() => {
164+
isFetchingRef.current = false
165+
})
166+
}, [hasNextPage])
167+
168+
// Calculate flattened data from pages
169+
const flatData = useMemo(() => {
170+
const result: Array<InferResultType<TContext>[number]> = []
171+
for (const page of pages) {
172+
result.push(...page)
173+
}
174+
return result as InferResultType<TContext>
175+
}, [pages])
176+
177+
return {
178+
data: flatData,
179+
pages,
180+
pageParams,
181+
fetchNextPage,
182+
hasNextPage,
183+
isFetchingNextPage: isFetchingRef.current,
184+
// Pass through useLiveQuery properties
185+
state: queryResult.state,
186+
collection: queryResult.collection,
187+
status: queryResult.status,
188+
isLoading: queryResult.isLoading,
189+
isReady: queryResult.isReady,
190+
isIdle: queryResult.isIdle,
191+
isError: queryResult.isError,
192+
isCleanedUp: queryResult.isCleanedUp,
193+
isEnabled: queryResult.isEnabled,
194+
}
195+
}

0 commit comments

Comments
 (0)