Skip to content

Commit 23323ae

Browse files
KyleAMathewsclaude
andcommitted
feat: implement useLiveInfiniteQuery hook for React
Implements Phase 1 of RFC #613 for infinite query pattern with live reactivity. - Add useLiveInfiniteQuery hook with TanStack Query-compatible API - Support pagination within collection's current dataset - Include pages, pageParams, fetchNextPage, hasNextPage - Automatically update pages when underlying data changes - Reset pagination when dependencies change - Add comprehensive test suite with 13 tests covering pagination, live updates, and edge cases Phase 1 uses client-side slicing. Future phases will add limit optimization and backend integration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 44555b7 commit 23323ae

File tree

3 files changed

+916
-0
lines changed

3 files changed

+916
-0
lines changed

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: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2+
import type {
3+
Context,
4+
InferResultType,
5+
InitialQueryBuilder,
6+
QueryBuilder,
7+
} from "@tanstack/db"
8+
import { useLiveQuery } from "./useLiveQuery"
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+
// Call user's getNextPageParam to determine if there's more
142+
const nextParam = config.getNextPageParam(
143+
lastPage,
144+
pages,
145+
lastPageParam,
146+
pageParams
147+
)
148+
149+
return nextParam !== undefined
150+
}, [pages, pageParams, config])
151+
152+
// Fetch next page
153+
const fetchNextPage = useCallback(() => {
154+
if (!hasNextPage || isFetchingRef.current) return
155+
156+
isFetchingRef.current = true
157+
setLoadedPageCount((prev) => prev + 1)
158+
159+
// Reset fetching state synchronously
160+
Promise.resolve().then(() => {
161+
isFetchingRef.current = false
162+
})
163+
}, [hasNextPage])
164+
165+
// Calculate flattened data from pages
166+
const flatData = useMemo(() => {
167+
const result: Array<InferResultType<TContext>[number]> = []
168+
for (const page of pages) {
169+
result.push(...page)
170+
}
171+
return result as InferResultType<TContext>
172+
}, [pages])
173+
174+
return {
175+
data: flatData,
176+
pages,
177+
pageParams,
178+
fetchNextPage,
179+
hasNextPage,
180+
isFetchingNextPage: isFetchingRef.current,
181+
// Pass through useLiveQuery properties
182+
state: queryResult.state,
183+
collection: queryResult.collection,
184+
status: queryResult.status,
185+
isLoading: queryResult.isLoading,
186+
isReady: queryResult.isReady,
187+
isIdle: queryResult.isIdle,
188+
isError: queryResult.isError,
189+
isCleanedUp: queryResult.isCleanedUp,
190+
isEnabled: queryResult.isEnabled,
191+
}
192+
}

0 commit comments

Comments
 (0)