Repository navigation
useLiveQuery: move an offset/limit page window without rebuilding the live query #2291
Description
Problem
With numbered pagination (jump to any page, page kept in the URL), the page has to be part of the query identity. So every page change creates a new live query collection and reruns the whole pipeline.
utils.setWindow() moves the window without rebuilding the query, but useLiveQuery can't drive it. It has to be called from a layout effect, so the hook first renders the old page, and the window is split between .limit().offset() and the effect (the split #1623 warns about).
useLiveInfiniteQuery only grows forward, so it can't jump to a page.
// Today: every page rebuilds everything
useLiveQuery({ queryKey: ['orders', filters, page], query: (q) => expensive(q).orderBy(...).limit(20).offset(page * 20) })
// Workaround
useLayoutEffect(() => { void collection.utils.setWindow({ offset: page * 20, limit: 20 }) }, [page])Proposal
useLiveQuery({
queryKey: ['orders', filters],
query: (q) => expensive(q).orderBy(...),
window: { offset: page * 20, limit: 20 }, // a change here only calls setWindow on the same collection
})Or a page mode in the shared window binding from #2235.
Benchmark
Setup: 5,000 orders, 20,000 items, a filter plus a grouped sum joined to every order, pages of 20, 10 page changes. @tanstack/db 0.15.1, Bun, 3 runs.
| First page | Each next page | |
|---|---|---|
Rebuild per page (bun bench.ts rebuild) |
392–456 ms | 221–244 ms |
setWindow (bun bench.ts setWindow) |
388–480 ms | 0.8–1.1 ms |
The benchmark has no includes. With on-demand materialize includes, setWindow publishes the new rows before their children load, so they fill in over the next renders (in our app: rows at about 300 ms, complete at about 600 ms).
bench.ts
// bun bench.ts rebuild | setWindow | chained | chainedIndex
import { BasicIndex, createCollection, createLiveQueryCollection, eq, localOnlyCollectionOptions, sum } from '@tanstack/db'
const indexed = { autoIndex: 'eager', defaultIndexType: BasicIndex } as const
const orders = createCollection(localOnlyCollectionOptions({ ...indexed, getKey: (o: any) => o.id,
initialData: Array.from({ length: 5000 }, (_, i) => ({ id: i, status: i % 3 ? 'open' : 'closed' })) }))
const items = createCollection(localOnlyCollectionOptions({ ...indexed, getKey: (i: any) => i.id,
initialData: Array.from({ length: 20000 }, (_, i) => ({ id: i, orderId: i % 5000, amount: (i * 7919) % 1000 })) }))
// Expensive part: filter + grouped aggregate joined to every order
const filtered = (q: any) => q.from({ order: orders })
.join({ t: q.from({ item: items }).groupBy(({ item }: any) => item.orderId)
.select(({ item }: any) => ({ orderId: item.orderId, total: sum(item.amount) })) },
({ order, t }: any) => eq(order.id, t.orderId))
.where(({ order }: any) => eq(order.status, 'open'))
.select(({ order, t }: any) => ({ id: order.id, total: t.total }))
const page = (q: any, from: any, offset: number) => q.from({ row: from })
.orderBy(({ row }: any) => row.total, 'desc').orderBy(({ row }: any) => row.id).limit(20).offset(offset)
const live = async (query: any) => {
const c = createLiveQueryCollection({ startSync: true, query })
await c.toArrayWhenReady()
return c as any
}
const mode = process.argv[2]
let warnings = 0
console.warn = () => warnings++
const times: Array<number> = []
const timed = async (fn: () => Promise<unknown>) => { const t = performance.now(); await fn(); times.push(performance.now() - t) }
if (mode === 'rebuild') {
// What useLiveQuery does when the page is part of the queryKey
for (let p = 0; p < 10; p++) await timed(async () => (await live((q: any) => page(q, filtered(q), p * 20))).cleanup())
} else if (mode === 'setWindow') {
let c: any
await timed(async () => { c = await live((q: any) => page(q, filtered(q), 0)) })
for (let p = 1; p < 10; p++) await timed(async () => { const r = c.utils.setWindow({ offset: p * 20, limit: 20 }); if (r !== true) await r })
} else {
// Base cached as its own live query collection, the page query over it is rebuilt per page
let base: any
await timed(async () => {
base = await live(filtered)
if (mode === 'chainedIndex') base.createIndex((row: any) => row.total, { indexType: BasicIndex })
})
for (let p = 0; p < 10; p++) await timed(async () => (await live((q: any) => page(q, base, p * 20))).cleanup())
}
const next = times.slice(-9)
console.log(mode, `first ${times[0]!.toFixed(1)} ms`, `next pages avg ${(next.reduce((a, b) => a + b) / next.length).toFixed(1)} ms`, `index warnings ${warnings}`)
Activity