Skip to content

Commit 2fb3fa9

Browse files
committed
use useSyncExternalStore for useLiveQuery
1 parent 24bdccd commit 2fb3fa9

File tree

4 files changed

+168
-77
lines changed

4 files changed

+168
-77
lines changed

docs/overview.md

Lines changed: 60 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ const todoCollection = createCollection({
3333

3434
const Todos = () => {
3535
// Bind data using live queries
36-
const { data: todos } = useLiveQuery((query) =>
37-
query
38-
.from({ todoCollection })
39-
.where('@completed', '=', false)
36+
const { data: todos } = useLiveQuery((q) =>
37+
q
38+
.from({ todo: todoCollection })
39+
.where(({ todo }) => eq(todo.completed, false))
4040
)
4141

4242
const complete = (todo) => {
@@ -258,22 +258,21 @@ Live queries return collections. This allows you to derive collections from othe
258258
For example:
259259

260260
```ts
261-
import { compileQuery, queryBuilder } from "@tanstack/db"
261+
import { createLiveQueryCollection, eq } from "@tanstack/db"
262262

263-
// Imagine you have a collections of todos.
263+
// Imagine you have a collection of todos.
264264
const todoCollection = createCollection({
265265
// config
266266
})
267267

268268
// You can derive a new collection that's a subset of it.
269-
const query = queryBuilder()
270-
.from({ todoCollection })
271-
.where('@completed', '=', true)
272-
273-
const compiled = compileQuery(query)
274-
compiled.start()
275-
276-
const completedTodoCollection = compiledQuery.results()
269+
const completedTodoCollection = createLiveQueryCollection({
270+
startSync: true,
271+
query: (q) =>
272+
q
273+
.from({ todo: todoCollection })
274+
.where(({ todo }) => eq(todo.completed, true))
275+
})
277276
```
278277

279278
This also works with joins to derive collections from multiple source collections. And it works recursively -- you can derive collections from other derived collections. Changes propagate efficiently using differential dataflow and it's collections all the way down.
@@ -292,14 +291,18 @@ Use the `useLiveQuery` hook to assign live query results to a state variable in
292291

293292
```ts
294293
import { useLiveQuery } from '@tanstack/react-db'
294+
import { eq } from '@tanstack/db'
295295

296296
const Todos = () => {
297-
const { data: todos } = useLiveQuery(query =>
298-
query
299-
.from({ todoCollection })
300-
.where('@completed', '=', false)
301-
.orderBy({'@created_at': 'asc'})
302-
.select('@id', '@text')
297+
const { data: todos } = useLiveQuery((q) =>
298+
q
299+
.from({ todo: todoCollection })
300+
.where(({ todo }) => eq(todo.completed, false))
301+
.orderBy(({ todo }) => todo.created_at, 'asc')
302+
.select(({ todo }) => ({
303+
id: todo.id,
304+
text: todo.text
305+
}))
303306
)
304307

305308
return <List items={ todos } />
@@ -310,18 +313,23 @@ You can also query across collections with joins:
310313

311314
```ts
312315
import { useLiveQuery } from '@tanstack/react-db'
316+
import { eq } from '@tanstack/db'
313317

314318
const Todos = () => {
315-
const { data: todos } = useLiveQuery(query =>
316-
query
319+
const { data: todos } = useLiveQuery((q) =>
320+
q
317321
.from({ todos: todoCollection })
318-
.join({
319-
type: `inner`,
320-
from: { lists: listCollection },
321-
on: [`@lists.id`, `=`, `@todos.listId`],
322-
})
323-
.where('@lists.active', '=', true)
324-
.select(`@todos.id`, `@todos.title`, `@lists.name`)
322+
.join(
323+
{ lists: listCollection },
324+
({ todos, lists }) => eq(lists.id, todos.listId),
325+
'inner'
326+
)
327+
.where(({ lists }) => eq(lists.active, true))
328+
.select(({ todos, lists }) => ({
329+
id: todos.id,
330+
title: todos.title,
331+
listName: lists.name
332+
}))
325333
)
326334

327335
return <List items={ todos } />
@@ -333,16 +341,16 @@ const Todos = () => {
333341
You can also build queries directly (outside of the component lifecycle) using the underlying `queryBuilder` API:
334342

335343
```ts
336-
import { compileQuery, queryBuilder } from "@tanstack/db"
344+
import { createLiveQueryCollection, eq } from "@tanstack/db"
337345
338-
const query = queryBuilder()
339-
.from({ todoCollection })
340-
.where('@completed', '=', true)
341-
342-
const compiled = compileQuery(query)
343-
compiled.start()
346+
const completedTodos = createLiveQueryCollection({
347+
startSync: true,
348+
query: (q) =>
349+
q.from({ todo: todoCollection })
350+
.where(({ todo }) => eq(todo.completed, true))
351+
})
344352
345-
const results = compiledQuery.results()
353+
const results = completedTodos.toArray
346354
```
347355

348356
Note also that:
@@ -575,16 +583,21 @@ const listCollection = createCollection<TodoList>(queryCollectionOptions({
575583
const Todos = () => {
576584
// Read the data using live queries. Here we show a live
577585
// query that joins across two collections.
578-
const { data: todos } = useLiveQuery((query) =>
579-
query
580-
.from({ t: todoCollection })
581-
.join({
582-
type: 'inner',
583-
from: { l: listCollection },
584-
on: [`@l.id`, `=`, `@t.list_id`]
585-
})
586-
.where('@l.active', '=', true)
587-
.select('@t.id', '@t.text', '@t.status', '@l.name')
586+
const { data: todos } = useLiveQuery((q) =>
587+
q
588+
.from({ todo: todoCollection })
589+
.join(
590+
{ list: listCollection },
591+
({ todo, list }) => eq(list.id, todo.list_id),
592+
'inner'
593+
)
594+
.where(({ list }) => eq(list.active, true))
595+
.select(({ todo, list }) => ({
596+
id: todo.id,
597+
text: todo.text,
598+
status: todo.status,
599+
listName: list.name
600+
}))
588601
)
589602
590603
// ...

examples/react/todo/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,8 @@ export default function App() {
350350
q.from({ config: configCollection })
351351
)
352352

353+
console.log(`RENDER`)
354+
353355
// Handle collection type change directly
354356
const handleCollectionTypeChange = (type: CollectionType) => {
355357
if (type !== collectionType) {

packages/db/src/query/live-query-collection.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ export interface LiveQueryCollectionConfig<
7979
* Start sync / the query immediately
8080
*/
8181
startSync?: boolean
82+
83+
/**
84+
* GC time for the collection
85+
*/
86+
gcTime?: number
8287
}
8388

8489
/**
@@ -322,6 +327,7 @@ export function liveQueryCollectionOptions<
322327
config.getKey || ((item) => resultKeys.get(item) as string | number),
323328
sync,
324329
compare,
330+
gcTime: config.gcTime || 5000, // 5 seconds by default for live queries
325331
schema: config.schema,
326332
onInsert: config.onInsert,
327333
onUpdate: config.onUpdate,

packages/react-db/src/useLiveQuery.ts

Lines changed: 100 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useState } from "react"
1+
import { useRef, useSyncExternalStore } from "react"
22
import { createLiveQueryCollection } from "@tanstack/db"
33
import type {
44
Collection,
@@ -55,30 +55,47 @@ export function useLiveQuery(
5555
typeof configOrQueryOrCollection.startSyncImmediate === `function` &&
5656
typeof configOrQueryOrCollection.id === `string`
5757

58-
const collection = useMemo(
59-
() => {
60-
if (isCollection) {
61-
// It's already a collection, ensure sync is started for React hooks
62-
configOrQueryOrCollection.startSyncImmediate()
63-
return configOrQueryOrCollection
64-
}
58+
// Use refs to cache collection and track dependencies
59+
const collectionRef = useRef<any>(null)
60+
const depsRef = useRef<Array<unknown> | null>(null)
61+
const configRef = useRef<any>(null)
62+
63+
// Check if we need to create/recreate the collection
64+
const needsNewCollection =
65+
!collectionRef.current ||
66+
(isCollection && configRef.current !== configOrQueryOrCollection) ||
67+
(!isCollection &&
68+
(depsRef.current === null ||
69+
depsRef.current.length !== deps.length ||
70+
depsRef.current.some((dep, i) => dep !== deps[i])))
6571

72+
if (needsNewCollection) {
73+
if (isCollection) {
74+
// It's already a collection, ensure sync is started for React hooks
75+
configOrQueryOrCollection.startSyncImmediate()
76+
collectionRef.current = configOrQueryOrCollection
77+
configRef.current = configOrQueryOrCollection
78+
} else {
6679
// Original logic for creating collections
6780
// Ensure we always start sync for React hooks
6881
if (typeof configOrQueryOrCollection === `function`) {
69-
return createLiveQueryCollection({
82+
collectionRef.current = createLiveQueryCollection({
7083
query: configOrQueryOrCollection,
7184
startSync: true,
85+
gcTime: 0, // Live queries created by useLiveQuery are cleaned up immediately
7286
})
7387
} else {
74-
return createLiveQueryCollection({
75-
...configOrQueryOrCollection,
88+
collectionRef.current = createLiveQueryCollection({
7689
startSync: true,
90+
gcTime: 0, // Live queries created by useLiveQuery are cleaned up immediately
91+
...configOrQueryOrCollection,
7792
})
7893
}
79-
},
80-
isCollection ? [configOrQueryOrCollection] : [...deps]
81-
)
94+
depsRef.current = [...deps]
95+
}
96+
}
97+
98+
const collection = collectionRef.current!
8299

83100
// Infer types from the actual collection
84101
type CollectionType =
@@ -88,25 +105,78 @@ export function useLiveQuery(
88105
? K
89106
: string | number
90107

91-
// Use a simple counter to force re-renders when collection changes
92-
const [, forceUpdate] = useState(0)
108+
// Use refs to track version and memoized snapshot
109+
const versionRef = useRef(0)
110+
const snapshotRef = useRef<{
111+
state: Map<KeyType, CollectionType>
112+
data: Array<CollectionType>
113+
collection: typeof collection
114+
_version: number
115+
} | null>(null)
93116

94-
useEffect(() => {
95-
// Subscribe to changes and force re-render
96-
const unsubscribe = collection.subscribeChanges(() => {
97-
forceUpdate((prev) => prev + 1)
98-
})
117+
// Create stable subscribe function using ref
118+
const subscribeRef = useRef<
119+
((onStoreChange: () => void) => () => void) | null
120+
>(null)
121+
if (!subscribeRef.current || needsNewCollection) {
122+
subscribeRef.current = (onStoreChange: () => void) => {
123+
const unsubscribe = collection.subscribeChanges(() => {
124+
versionRef.current += 1
125+
onStoreChange()
126+
})
127+
return () => {
128+
unsubscribe()
129+
}
130+
}
131+
}
132+
133+
// Create stable getSnapshot function using ref
134+
const getSnapshotRef = useRef<
135+
| (() => {
136+
state: Map<KeyType, CollectionType>
137+
data: Array<CollectionType>
138+
collection: typeof collection
139+
})
140+
| null
141+
>(null)
142+
if (!getSnapshotRef.current || needsNewCollection) {
143+
getSnapshotRef.current = (): {
144+
state: Map<KeyType, CollectionType>
145+
data: Array<CollectionType>
146+
collection: typeof collection
147+
} => {
148+
const currentVersion = versionRef.current
149+
150+
// If we don't have a snapshot or the version changed, create a new one
151+
if (
152+
!snapshotRef.current ||
153+
snapshotRef.current._version !== currentVersion
154+
) {
155+
snapshotRef.current = {
156+
get state(): Map<KeyType, CollectionType> {
157+
return new Map(collection.entries())
158+
},
159+
get data(): Array<CollectionType> {
160+
return Array.from(collection.values())
161+
},
162+
collection,
163+
_version: currentVersion,
164+
}
165+
}
99166

100-
return unsubscribe
101-
}, [collection])
167+
return snapshotRef.current
168+
}
169+
}
170+
171+
// Use useSyncExternalStore to subscribe to collection changes
172+
const snapshot = useSyncExternalStore(
173+
subscribeRef.current,
174+
getSnapshotRef.current
175+
)
102176

103177
return {
104-
get state(): Map<KeyType, CollectionType> {
105-
return new Map(collection.entries())
106-
},
107-
get data(): Array<CollectionType> {
108-
return Array.from(collection.values())
109-
},
110-
collection,
178+
state: snapshot.state,
179+
data: snapshot.data,
180+
collection: snapshot.collection,
111181
}
112182
}

0 commit comments

Comments
 (0)