diff --git a/.changeset/spotty-dogs-fry.md b/.changeset/spotty-dogs-fry.md new file mode 100644 index 000000000..337b196f3 --- /dev/null +++ b/.changeset/spotty-dogs-fry.md @@ -0,0 +1,47 @@ +--- +"@tanstack/react-db": patch +--- + +Expand `useLiveQuery` callback to support conditional queries and additional return types, enabling the ability to temporarily disable the query. + +**New Features:** + +- Callback can now return `undefined` or `null` to temporarily disable the query +- Callback can return a pre-created `Collection` instance to use it directly +- Callback can return a `LiveQueryCollectionConfig` object for advanced configuration +- When disabled (returning `undefined`/`null`), the hook returns a specific idle state + +**Usage Examples:** + +```ts +// Conditional queries - disable when not ready +const enabled = useState(false) +const { data, state, isIdle } = useLiveQuery((q) => { + if (!enabled) return undefined // Disables the query + return q.from({ users }).where(...) +}, [enabled]) + +/** + * When disabled, returns: + * { + * state: undefined, + * data: undefined, + * isIdle: true, + * ... + * } + */ + +// Return pre-created Collection +const { data } = useLiveQuery((q) => { + if (usePrebuilt) return myCollection // Use existing collection + return q.from({ items }).select(...) +}, [usePrebuilt]) + +// Return LiveQueryCollectionConfig +const { data } = useLiveQuery((q) => { + return { + query: q.from({ items }).select(...), + id: `my-collection`, + } +}) +``` diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index f2dcd4a5c..d4df4904e 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1,5 +1,9 @@ import { useRef, useSyncExternalStore } from "react" -import { createLiveQueryCollection } from "@tanstack/db" +import { + BaseQueryBuilder, + CollectionImpl, + createLiveQueryCollection, +} from "@tanstack/db" import type { Collection, CollectionStatus, @@ -10,6 +14,10 @@ import type { QueryBuilder, } from "@tanstack/db" +const DEFAULT_GC_TIME_MS = 1 // Live queries created by useLiveQuery are cleaned up immediately (0 disables GC) + +export type UseLiveQueryStatus = CollectionStatus | `disabled` + /** * Create a live query using a query function * @param queryFn - Query function that defines what data to fetch @@ -60,7 +68,7 @@ import type { * * ) */ -// Overload 1: Accept just the query function +// Overload 1: Accept query function that always returns QueryBuilder export function useLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder, deps?: Array @@ -68,12 +76,109 @@ export function useLiveQuery( state: Map> data: Array> collection: Collection, string | number, {}> - status: CollectionStatus + status: CollectionStatus // Can't be disabled if always returns QueryBuilder + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: true // Always true if always returns QueryBuilder +} + +// Overload 2: Accept query function that can return undefined/null +export function useLiveQuery( + queryFn: ( + q: InitialQueryBuilder + ) => QueryBuilder | undefined | null, + deps?: Array +): { + state: Map> | undefined + data: Array> | undefined + collection: Collection, string | number, {}> | undefined + status: UseLiveQueryStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: boolean +} + +// Overload 3: Accept query function that can return LiveQueryCollectionConfig +export function useLiveQuery( + queryFn: ( + q: InitialQueryBuilder + ) => LiveQueryCollectionConfig | undefined | null, + deps?: Array +): { + state: Map> | undefined + data: Array> | undefined + collection: Collection, string | number, {}> | undefined + status: UseLiveQueryStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: boolean +} + +// Overload 4: Accept query function that can return Collection +export function useLiveQuery< + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + queryFn: ( + q: InitialQueryBuilder + ) => Collection | undefined | null, + deps?: Array +): { + state: Map | undefined + data: Array | undefined + collection: Collection | undefined + status: UseLiveQueryStatus isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean + isEnabled: boolean +} + +// Overload 5: Accept query function that can return all types +export function useLiveQuery< + TContext extends Context, + TResult extends object, + TKey extends string | number, + TUtils extends Record, +>( + queryFn: ( + q: InitialQueryBuilder + ) => + | QueryBuilder + | LiveQueryCollectionConfig + | Collection + | undefined + | null, + deps?: Array +): { + state: + | Map> + | Map + | undefined + data: Array> | Array | undefined + collection: + | Collection, string | number, {}> + | Collection + | undefined + status: UseLiveQueryStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: boolean } /** @@ -109,7 +214,7 @@ export function useLiveQuery( * * return
{data.length} items loaded
*/ -// Overload 2: Accept config object +// Overload 6: Accept config object export function useLiveQuery( config: LiveQueryCollectionConfig, deps?: Array @@ -117,12 +222,13 @@ export function useLiveQuery( state: Map> data: Array> collection: Collection, string | number, {}> - status: CollectionStatus + status: CollectionStatus // Can't be disabled for config objects isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean + isEnabled: true // Always true for config objects } /** @@ -154,7 +260,7 @@ export function useLiveQuery( * * return
{data.map(item => )}
*/ -// Overload 3: Accept pre-created live query collection +// Overload 7: Accept pre-created live query collection export function useLiveQuery< TResult extends object, TKey extends string | number, @@ -165,12 +271,13 @@ export function useLiveQuery< state: Map data: Array collection: Collection - status: CollectionStatus + status: CollectionStatus // Can't be disabled for pre-created live query collections isLoading: boolean isReady: boolean isIdle: boolean isError: boolean isCleanedUp: boolean + isEnabled: true // Always true for pre-created live query collections } // Implementation - use function overloads to infer the actual collection type @@ -193,6 +300,13 @@ export function useLiveQuery( const depsRef = useRef | null>(null) const configRef = useRef(null) + // Use refs to track version and memoized snapshot + const versionRef = useRef(0) + const snapshotRef = useRef<{ + collection: Collection | null + version: number + } | null>(null) + // Check if we need to create/recreate the collection const needsNewCollection = !collectionRef.current || @@ -209,32 +323,53 @@ export function useLiveQuery( collectionRef.current = configOrQueryOrCollection configRef.current = configOrQueryOrCollection } else { - // Original logic for creating collections - // Ensure we always start sync for React hooks + // Handle different callback return types if (typeof configOrQueryOrCollection === `function`) { - collectionRef.current = createLiveQueryCollection({ - query: configOrQueryOrCollection, - startSync: true, - gcTime: 0, // Live queries created by useLiveQuery are cleaned up immediately - }) as unknown as Collection + // Call the function with a query builder to see what it returns + const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder + const result = configOrQueryOrCollection(queryBuilder) + + if (result === undefined || result === null) { + // Callback returned undefined/null - disabled query + collectionRef.current = null + } else if (result instanceof CollectionImpl) { + // Callback returned a Collection instance - use it directly + result.startSyncImmediate() + collectionRef.current = result + } else if (result instanceof BaseQueryBuilder) { + // Callback returned QueryBuilder - create live query collection using the original callback + // (not the result, since the result might be from a different query builder instance) + collectionRef.current = createLiveQueryCollection({ + query: configOrQueryOrCollection, + startSync: true, + gcTime: DEFAULT_GC_TIME_MS, + }) + } else if (result && typeof result === `object`) { + // Assume it's a LiveQueryCollectionConfig + collectionRef.current = createLiveQueryCollection({ + startSync: true, + gcTime: DEFAULT_GC_TIME_MS, + ...result, + }) + } else { + // Unexpected return type + throw new Error( + `useLiveQuery callback must return a QueryBuilder, LiveQueryCollectionConfig, Collection, undefined, or null. Got: ${typeof result}` + ) + } + depsRef.current = [...deps] } else { + // Original logic for config objects collectionRef.current = createLiveQueryCollection({ startSync: true, - gcTime: 0, // Live queries created by useLiveQuery are cleaned up immediately + gcTime: DEFAULT_GC_TIME_MS, ...configOrQueryOrCollection, - }) as unknown as Collection + }) + depsRef.current = [...deps] } - depsRef.current = [...deps] } } - // Use refs to track version and memoized snapshot - const versionRef = useRef(0) - const snapshotRef = useRef<{ - collection: Collection - version: number - } | null>(null) - // Reset refs when collection changes if (needsNewCollection) { versionRef.current = 0 @@ -247,13 +382,18 @@ export function useLiveQuery( >(null) if (!subscribeRef.current || needsNewCollection) { subscribeRef.current = (onStoreChange: () => void) => { - const unsubscribe = collectionRef.current!.subscribeChanges(() => { + // If no collection, return a no-op unsubscribe function + if (!collectionRef.current) { + return () => {} + } + + const unsubscribe = collectionRef.current.subscribeChanges(() => { // Bump version on any change; getSnapshot will rebuild next time versionRef.current += 1 onStoreChange() }) // Collection may be ready and will not receive initial `subscribeChanges()` - if (collectionRef.current!.status === `ready`) { + if (collectionRef.current.status === `ready`) { versionRef.current += 1 onStoreChange() } @@ -266,7 +406,7 @@ export function useLiveQuery( // Create stable getSnapshot function using ref const getSnapshotRef = useRef< | (() => { - collection: Collection + collection: Collection | null version: number }) | null @@ -274,7 +414,7 @@ export function useLiveQuery( if (!getSnapshotRef.current || needsNewCollection) { getSnapshotRef.current = () => { const currentVersion = versionRef.current - const currentCollection = collectionRef.current! + const currentCollection = collectionRef.current // Recreate snapshot object only if version/collection changed if ( @@ -300,7 +440,7 @@ export function useLiveQuery( // Track last snapshot (from useSyncExternalStore) and the returned value separately const returnedSnapshotRef = useRef<{ - collection: Collection + collection: Collection | null version: number } | null>(null) // Keep implementation return loose to satisfy overload signatures @@ -312,33 +452,50 @@ export function useLiveQuery( returnedSnapshotRef.current.version !== snapshot.version || returnedSnapshotRef.current.collection !== snapshot.collection ) { - // Capture a stable view of entries for this snapshot to avoid tearing - const entries = Array.from(snapshot.collection.entries()) - let stateCache: Map | null = null - let dataCache: Array | null = null + // Handle null collection case (when callback returns undefined/null) + if (!snapshot.collection) { + returnedRef.current = { + state: undefined, + data: undefined, + collection: undefined, + status: `disabled`, + isLoading: false, + isReady: false, + isIdle: false, + isError: false, + isCleanedUp: false, + isEnabled: false, + } + } else { + // Capture a stable view of entries for this snapshot to avoid tearing + const entries = Array.from(snapshot.collection.entries()) + let stateCache: Map | null = null + let dataCache: Array | null = null - returnedRef.current = { - get state() { - if (!stateCache) { - stateCache = new Map(entries) - } - return stateCache - }, - get data() { - if (!dataCache) { - dataCache = entries.map(([, value]) => value) - } - return dataCache - }, - collection: snapshot.collection, - status: snapshot.collection.status, - isLoading: - snapshot.collection.status === `loading` || - snapshot.collection.status === `initialCommit`, - isReady: snapshot.collection.status === `ready`, - isIdle: snapshot.collection.status === `idle`, - isError: snapshot.collection.status === `error`, - isCleanedUp: snapshot.collection.status === `cleaned-up`, + returnedRef.current = { + get state() { + if (!stateCache) { + stateCache = new Map(entries) + } + return stateCache + }, + get data() { + if (!dataCache) { + dataCache = entries.map(([, value]) => value) + } + return dataCache + }, + collection: snapshot.collection, + status: snapshot.collection.status, + isLoading: + snapshot.collection.status === `loading` || + snapshot.collection.status === `initialCommit`, + isReady: snapshot.collection.status === `ready`, + isIdle: snapshot.collection.status === `idle`, + isError: snapshot.collection.status === `error`, + isCleanedUp: snapshot.collection.status === `cleaned-up`, + isEnabled: true, + } } // Remember the snapshot that produced this returned value diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 9704f0c99..30374d1af 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -8,6 +8,7 @@ import { createOptimisticAction, eq, gt, + lte, } from "@tanstack/db" import { useEffect } from "react" import { useLiveQuery } from "../src/useLiveQuery" @@ -333,7 +334,7 @@ describe(`Query Collections`, () => { .select(({ issues, persons }) => ({ id: issues.id, title: issues.title, - name: persons.name, + name: persons?.name, })) ) }) @@ -708,7 +709,7 @@ describe(`Query Collections`, () => { .select(({ issues, persons }) => ({ id: issues.id, title: issues.title, - name: persons.name, + name: persons?.name, })) ) @@ -1296,7 +1297,7 @@ describe(`Query Collections`, () => { .select(({ issues, persons }) => ({ id: issues.id, title: issues.title, - name: persons.name, + name: persons?.name, })) ) }) @@ -1444,4 +1445,385 @@ describe(`Query Collections`, () => { // The main test is that isReady remains true when parameters change }) }) + + describe(`callback variants with conditional returns`, () => { + it(`should handle callback returning undefined with proper state`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `undefined-callback-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => { + return useLiveQuery( + (q) => { + if (!enabled) return undefined + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + }, + [enabled] + ) + }, + { initialProps: { enabled: false } } + ) + + // When callback returns undefined, should return the specified state + expect(result.current.state).toBeUndefined() + expect(result.current.data).toBeUndefined() + expect(result.current.collection).toBeUndefined() + expect(result.current.status).toBe(`disabled`) + expect(result.current.isLoading).toBe(false) + expect(result.current.isReady).toBe(false) + expect(result.current.isIdle).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.isCleanedUp).toBe(false) + + // Enable the query + act(() => { + rerender({ enabled: true }) + }) + + // Wait for collection to sync and state to update + await waitFor(() => { + expect(result.current.state?.size).toBe(1) // Only John Smith (age 35) + }) + expect(result.current.data).toHaveLength(1) + expect(result.current.collection).toBeDefined() + expect(result.current.status).toBeDefined() + expect(result.current.isLoading).toBe(false) + expect(result.current.isReady).toBe(true) + expect(result.current.isIdle).toBe(false) + + const johnSmith = result.current.data![0] + expect(johnSmith).toMatchObject({ + id: `3`, + name: `John Smith`, + age: 35, + }) + + // Disable the query again + act(() => { + rerender({ enabled: false }) + }) + + // Should return to undefined state + expect(result.current.state).toBeUndefined() + expect(result.current.data).toBeUndefined() + expect(result.current.collection).toBeUndefined() + expect(result.current.status).toBe(`disabled`) + expect(result.current.isLoading).toBe(false) + expect(result.current.isReady).toBe(false) + expect(result.current.isIdle).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.isCleanedUp).toBe(false) + }) + + it(`should handle callback returning null with proper state`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `null-callback-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => { + return useLiveQuery( + (q) => { + if (!enabled) return null + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + }, + [enabled] + ) + }, + { initialProps: { enabled: false } } + ) + + // When callback returns null, should return the specified state + expect(result.current.state).toBeUndefined() + expect(result.current.data).toBeUndefined() + expect(result.current.collection).toBeUndefined() + expect(result.current.status).toBe(`disabled`) + expect(result.current.isLoading).toBe(false) + expect(result.current.isReady).toBe(false) + expect(result.current.isIdle).toBe(false) + expect(result.current.isError).toBe(false) + expect(result.current.isCleanedUp).toBe(false) + + // Enable the query + act(() => { + rerender({ enabled: true }) + }) + + // Wait for collection to sync and state to update + await waitFor(() => { + expect(result.current.state?.size).toBe(1) // Only John Smith (age 35) + }) + expect(result.current.data).toHaveLength(1) + expect(result.current.collection).toBeDefined() + expect(result.current.status).toBeDefined() + expect(result.current.isLoading).toBe(false) + expect(result.current.isReady).toBe(true) + expect(result.current.isIdle).toBe(false) + }) + + it(`should handle callback returning LiveQueryCollectionConfig`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `config-callback-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const { result, rerender } = renderHook( + ({ useConfig }: { useConfig: boolean }) => { + return useLiveQuery( + (q) => { + if (useConfig) { + return { + query: q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })), + startSync: true, + gcTime: 0, + } + } + return q + .from({ persons: collection }) + .where(({ persons }) => lte(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + .orderBy(({ persons }) => persons.age) + }, + [useConfig] + ) + }, + { initialProps: { useConfig: false } } + ) + + // Wait for collection to sync and state to update + await waitFor(() => { + expect(result.current.state?.size).toBe(2) // John Smith (age 35) and Jane Doe (age 25) + }) + expect(result.current.data).toHaveLength(2) + expect(result.current.collection).toBeDefined() + expect(result.current.status).toBeDefined() + + expect(result.current.data).toMatchObject([ + { + id: `2`, + name: `Jane Doe`, + age: 25, + }, + { + id: `1`, + name: `John Doe`, + age: 30, + }, + ]) + + // Switch to using config + act(() => { + rerender({ useConfig: true }) + }) + + // Should still work with config + await waitFor(() => { + expect(result.current.state?.size).toBe(1) + }) + expect(result.current.data).toHaveLength(1) + expect(result.current.collection).toBeDefined() + expect(result.current.status).toBeDefined() + + expect(result.current.data).toMatchObject([ + { + id: `3`, + name: `John Smith`, + age: 35, + }, + ]) + }) + + it(`should handle callback returning Collection`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `collection-callback-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + // Create a live query collection beforehand + const liveQueryCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })), + startSync: true, + }) + + const { result, rerender } = renderHook( + ({ useCollection }: { useCollection: boolean }) => { + return useLiveQuery( + (q) => { + if (useCollection) { + return liveQueryCollection + } + return q + .from({ persons: collection }) + .where(({ persons }) => lte(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + }, + [useCollection] + ) + }, + { initialProps: { useCollection: false } } + ) + + // Wait for collection to sync and state to update + await waitFor(() => { + expect(result.current.state?.size).toBe(2) // Only John Smith (age 35) + }) + expect(result.current.data).toHaveLength(2) + expect(result.current.collection).toBeDefined() + expect(result.current.status).toBeDefined() + + expect(result.current.data).toMatchObject([ + { + id: `2`, + name: `Jane Doe`, + age: 25, + }, + { + id: `1`, + name: `John Doe`, + age: 30, + }, + ]) + + // Switch to using pre-created collection + act(() => { + rerender({ useCollection: true }) + }) + + // Should still work with pre-created collection + await waitFor(() => { + expect(result.current.state?.size).toBe(1) // Only John Smith (age 35) + }) + expect(result.current.data).toHaveLength(1) + expect(result.current.collection).toBeDefined() + expect(result.current.status).toBeDefined() + expect(result.current.collection).toBe(liveQueryCollection) + + expect(result.current.data).toMatchObject([ + { + id: `3`, + name: `John Smith`, + age: 35, + }, + ]) + }) + + it(`should handle conditional returns with dependencies`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `conditional-deps-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }) + ) + + const { result, rerender } = renderHook( + ({ minAge, enabled }: { minAge: number; enabled: boolean }) => { + return useLiveQuery( + (q) => { + if (!enabled) return undefined + return q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, minAge)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) + }, + [minAge, enabled] + ) + }, + { initialProps: { minAge: 30, enabled: false } } + ) + + // Initially disabled + expect(result.current.state).toBeUndefined() + expect(result.current.data).toBeUndefined() + expect(result.current.status).toBe(`disabled`) + expect(result.current.isEnabled).toBe(false) + + // Enable with minAge 30 + act(() => { + rerender({ minAge: 30, enabled: true }) + }) + + await waitFor(() => { + expect(result.current.state?.size).toBe(1) // Only John Smith (age 35) + }) + expect(result.current.data).toHaveLength(1) + expect(result.current.isIdle).toBe(false) + + // Change minAge to 25 (should include more people) + act(() => { + rerender({ minAge: 25, enabled: true }) + }) + + await waitFor(() => { + expect(result.current.state?.size).toBe(2) // People with age > 25 (ages 30, 35) + }) + expect(result.current.data).toHaveLength(2) + + // Disable again + act(() => { + rerender({ minAge: 25, enabled: false }) + }) + + expect(result.current.state).toBeUndefined() + expect(result.current.data).toBeUndefined() + expect(result.current.status).toBe(`disabled`) + expect(result.current.isEnabled).toBe(false) + }) + }) })