Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/support-conditional-live-query-configs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/db': patch
'@tanstack/react-db': patch
---

Support disabling live queries declared with the `{ query }` config syntax by returning `undefined` or `null` from the query callback.
19 changes: 17 additions & 2 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,26 @@ function TodoList({ userId }: { userId: string }) {
}
```

The callback form can also return `undefined` or `null` to disable a query. This still uses derived identity, so captured structured values do not need a dependency array. When the query is disabled:
The `query` callback can return `undefined` or `null` to disable a query. This still uses derived identity, so captured structured values do not need a dependency array:

```tsx
const { data, isEnabled, status } = useLiveQuery({
query: (q) => {
if (!userId) return undefined

return q
.from({ todos: todosCollection })
.where(({ todos }) => eq(todos.userId, userId))
},
})
```

The top-level callback form supports the same behavior. When the query is disabled:
- `status` is `'disabled'`
- `data`, `state`, and `collection` are `undefined`
- `isEnabled` is `false`
- `isLoading`, `isReady`, `isIdle`, and `isError` are all `false`
- `isReady` is `true`
- `isLoading`, `isIdle`, `isError`, and `isCleanedUp` are all `false`

### Alternative Input Forms

Expand Down
30 changes: 23 additions & 7 deletions packages/db/src/live-query-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import type { CollectionImpl } from './collection/index.js'
import type { CollectionOptionsIdentity } from './collection-options.js'
import type { CollectionOptions, DbClient } from './client.js'
import type {
Context,
InitialQueryBuilder,
LiveQueryCollectionConfig,
QueryBuilder,
} from './query/index.js'

export type LiveQueryKey = ReadonlyArray<unknown>
Expand All @@ -22,6 +24,17 @@ export type DeferredLiveQueryCollections = Set<
CollectionImpl<any, string | number, any, any, any>
>

type PreparedLiveQueryConfigInput = Omit<
LiveQueryCollectionConfig<Context>,
`query`
> & {
query:
| QueryBuilder<Context>
| ((q: InitialQueryBuilder) => QueryBuilder<Context> | undefined | null)
queryKey?: LiveQueryKey
client?: DbClient
}

function createInitialQueryBuilder(
dbClient: DbClient | undefined,
deferredCollections: DeferredLiveQueryCollections,
Expand Down Expand Up @@ -73,17 +86,20 @@ export function prepareLiveQueryValue(
queryKey: _queryKey,
client: _client,
...config
} = value as LiveQueryCollectionConfig<any> & {
queryKey?: LiveQueryKey
client?: DbClient
} = value as PreparedLiveQueryConfigInput

const preparedQuery =
typeof query === `function`
? query(createInitialQueryBuilder(dbClient, deferredCollections))
: query

if (preparedQuery === undefined || preparedQuery === null) {
return preparedQuery
}

return {
...config,
query:
typeof query === `function`
? query(createInitialQueryBuilder(dbClient, deferredCollections))
: query,
query: preparedQuery,
}
}

Expand Down
16 changes: 16 additions & 0 deletions packages/db/tests/live-query-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,25 @@ import { createCollection } from '../src/collection/index.js'
import {
getLiveQueryHash,
getPreparedLiveQueryIdentity,
prepareLiveQueryValue,
} from '../src/live-query-options.js'
import { BaseQueryBuilder } from '../src/query/builder/index.js'

describe(`live query preparation`, () => {
it.each([undefined, null])(
`promotes a nullish config query result to a disabled query`,
(disabled) => {
const prepared = prepareLiveQueryValue(
{ query: () => disabled },
undefined,
new Set(),
)

expect(prepared).toBe(disabled)
},
)
})

describe(`live query identity`, () => {
it(`hashes Map values in an explicit queryKey deterministically`, () => {
const first = getLiveQueryHash(undefined, [
Expand Down
12 changes: 7 additions & 5 deletions packages/react-db/skills/react-db/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,13 @@ const { data } = useLiveQuery({
const { data } = useLiveQuery(preloadedCollection)

// Conditional query — derived identity handles enabled/disabled transitions
const { data, status } = useLiveQuery((q) => {
if (!userId) return undefined
return q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.userId, userId))
const { data, status } = useLiveQuery({
query: (q) => {
if (!userId) return undefined
return q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.userId, userId))
},
})
// When disabled: status='disabled', data=undefined
```
Expand Down
1 change: 1 addition & 0 deletions packages/react-db/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Re-export all public APIs
export { useLiveQuery } from './useLiveQuery'
export type {
ConditionalUseLiveQueryConfig,
LiveQueryKey,
UseLiveQueryConfig,
UseLiveQueryStatus,
Expand Down
95 changes: 79 additions & 16 deletions packages/react-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,36 @@ export type DerivedIdentityProfiler = {

export type UseLiveQueryStatus = CollectionStatus | `disabled`
export type LiveQueryKey = ReadonlyArray<unknown>
type UseLiveQueryConfigOptions<TContext extends Context> = Omit<
LiveQueryCollectionConfig<TContext>,
`query`
> & {
/**
* Explicit identity for queries that contain opaque functional variants or
* are hot enough that deriving identity from structured IR is too expensive.
* Structured queries should omit this so DB can derive identity directly.
*/
queryKey?: LiveQueryKey
/** Override the nearest DbProvider for this query. */
client?: DbClient
}

type ConfiguredQueryBuilder<TContext extends Context> = Extract<
LiveQueryCollectionConfig<TContext>[`query`],
QueryBuilder<TContext>
>

export type UseLiveQueryConfig<TContext extends Context> =
LiveQueryCollectionConfig<TContext> & {
/**
* Explicit identity for queries that contain opaque functional variants or
* are hot enough that deriving identity from structured IR is too expensive.
* Structured queries should omit this so DB can derive identity directly.
*/
queryKey?: LiveQueryKey
/** Override the nearest DbProvider for this query. */
client?: DbClient
UseLiveQueryConfigOptions<TContext> &
Pick<LiveQueryCollectionConfig<TContext>, `query`>

export type ConditionalUseLiveQueryConfig<TContext extends Context> =
UseLiveQueryConfigOptions<TContext> & {
query:
| ConfiguredQueryBuilder<TContext>
| ((
q: InitialQueryBuilder,
) => ConfiguredQueryBuilder<TContext> | undefined | null)
}

export function warnDeprecatedDepsArray(
Expand Down Expand Up @@ -297,6 +317,16 @@ function createCollectionFromPreparedQuery(value: unknown) {
* })
*
* @example
* // Return undefined or null to disable a query
* const { data, isEnabled } = useLiveQuery({
* query: (q) => {
* if (!userId) return undefined
* return q.from({ todos: todosCollection })
* .where(({ todos }) => eq(todos.userId, userId))
* },
* })
*
* @example
* // Join pattern
* const { data } = useLiveQuery({
* query: (q) =>
Expand Down Expand Up @@ -481,30 +511,63 @@ export function useLiveQuery<TContext extends Context>(
state: Map<string | number, GetResult<TContext>>
data: InferResultType<TContext>
collection: Collection<GetResult<TContext>, string | number, {}>
status: CollectionStatus // Can't be disabled for config objects
status: CollectionStatus // Can't be disabled when query always returns a builder
isLoading: boolean
isReady: boolean
isIdle: boolean
isError: boolean
isCleanedUp: boolean
isEnabled: true // Always true when query always returns a builder
}

// Overload 7: Accept config object with a query that can return undefined/null
export function useLiveQuery<TContext extends Context>(
config: ConditionalUseLiveQueryConfig<TContext>,
): {
state: Map<string | number, GetResult<TContext>> | undefined
data: InferResultType<TContext> | undefined
collection: Collection<GetResult<TContext>, string | number, {}> | undefined
status: UseLiveQueryStatus
isLoading: boolean
isReady: boolean
isIdle: boolean
isError: boolean
isCleanedUp: boolean
isEnabled: true // Always true for config objects
isEnabled: boolean
}

// Overload 7: Accept config object with legacy deps
// Overload 8: Accept config object with legacy deps
export function useLiveQuery<TContext extends Context>(
config: LiveQueryCollectionConfig<TContext>,
deps?: Array<unknown>,
): {
state: Map<string | number, GetResult<TContext>>
data: InferResultType<TContext>
collection: Collection<GetResult<TContext>, string | number, {}>
status: CollectionStatus // Can't be disabled for config objects
status: CollectionStatus // Can't be disabled when query always returns a builder
isLoading: boolean
isReady: boolean
isIdle: boolean
isError: boolean
isCleanedUp: boolean
isEnabled: true // Always true for config objects
isEnabled: true // Always true when query always returns a builder
}

// Overload 9: Accept config object with legacy deps and a query that can return undefined/null
export function useLiveQuery<TContext extends Context>(
config: ConditionalUseLiveQueryConfig<TContext>,
deps: Array<unknown>,
): {
state: Map<string | number, GetResult<TContext>> | undefined
data: InferResultType<TContext> | undefined
collection: Collection<GetResult<TContext>, string | number, {}> | undefined
status: UseLiveQueryStatus
isLoading: boolean
isReady: boolean
isIdle: boolean
isError: boolean
isCleanedUp: boolean
isEnabled: boolean
}

/**
Expand Down Expand Up @@ -536,7 +599,7 @@ export function useLiveQuery<TContext extends Context>(
*
* return <div>{data.map(item => <Item key={item.id} {...item} />)}</div>
*/
// Overload 8: Accept pre-created live query collection
// Overload 10: Accept pre-created live query collection
export function useLiveQuery<
TResult extends object,
TKey extends string | number,
Expand All @@ -556,7 +619,7 @@ export function useLiveQuery<
isEnabled: true // Always true for pre-created live query collections
}

// Overload 9: Accept pre-created live query collection with singleResult: true
// Overload 10: Accept pre-created live query collection with singleResult: true
export function useLiveQuery<
TResult extends object,
TKey extends string | number,
Expand Down
Loading
Loading