@tanstack/react-db
Version:
React integration for @tanstack/db
360 lines (337 loc) • 12.2 kB
text/typescript
'use client'
import { useRef } from 'react'
import { useLiveQueryForSuspense } from './useLiveQuery'
import { getLiveQueryResultInfo } from './live-query-internals'
import type { UseLiveQueryConfig } from './useLiveQuery'
import type {
Collection,
Context,
DbClient,
GetResult,
InferResultType,
InitialQueryBuilder,
LiveQueryCollectionConfig,
NonSingleResult,
QueryBuilder,
SingleResult,
} from '@tanstack/db'
// React can discard a render that suspends, including its refs. Keep the
// initial-render failure across retries, scoped by client because streamed
// preloads of the same collection can fail independently.
const initialRenderErrors = new WeakMap<
Collection<any, any, any>,
{
byClient: WeakMap<DbClient, { error: unknown; clientQuery?: object }>
unscoped?: { error: unknown; clientQuery?: object }
}
>()
function clearInitialRenderError(
collection: Collection<any, any, any>,
client: DbClient | undefined,
) {
const entry = initialRenderErrors.get(collection)
if (!entry) return
if (client) entry.byClient.delete(client)
else delete entry.unscoped
}
function rememberInitialRenderError(
collection: Collection<any, any, any>,
client: DbClient | undefined,
error: unknown,
clientQuery?: object,
) {
if (collection.status === `cleaned-up`) return
let entry = initialRenderErrors.get(collection)
if (!entry) {
entry = { byClient: new WeakMap() }
collection.once(`status:cleaned-up`, () => {
initialRenderErrors.delete(collection)
})
initialRenderErrors.set(collection, entry)
}
if (client) entry.byClient.set(client, { error, clientQuery })
else entry.unscoped = { error }
}
/**
* Create a live query with React Suspense support
* @param queryFn - Query function that defines what data to fetch
* @param deps - Deprecated array of dependencies that trigger query re-execution when changed
* @returns Object with reactive data and state - data is guaranteed to be defined
* @throws Promise when data is loading (caught by Suspense boundary)
* @throws Error when collection fails (caught by Error boundary)
* @example
* // Basic usage with Suspense
* function TodoList() {
* const { data } = useLiveSuspenseQuery({
* query: (q) =>
* q.from({ todos: todosCollection })
* .where(({ todos }) => eq(todos.completed, false))
* .select(({ todos }) => ({ id: todos.id, text: todos.text }))
* })
*
* return (
* <ul>
* {data.map(todo => <li key={todo.id}>{todo.text}</li>)}
* </ul>
* )
* }
*
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <TodoList />
* </Suspense>
* )
* }
*
* @example
* // Single result query
* const { data } = useLiveSuspenseQuery(
* (q) => q.from({ todos: todosCollection })
* .where(({ todos }) => eq(todos.id, 1))
* .findOne()
* )
* // data is guaranteed to be the single item (or undefined if not found)
*
* @example
* // Structured captured values are included in derived query identity and trigger re-suspension
* const { data } = useLiveSuspenseQuery({
* query: (q) => q.from({ todos: todosCollection })
* .where(({ todos }) => gt(todos.priority, minPriority)),
* })
*
* @example
* // With Error boundary
* function App() {
* return (
* <ErrorBoundary fallback={<div>Error loading data</div>}>
* <Suspense fallback={<div>Loading...</div>}>
* <TodoList />
* </Suspense>
* </ErrorBoundary>
* )
* }
*
* @remarks
* **Important:** This hook does NOT support disabled queries (returning undefined/null).
* Following TanStack Query's useSuspenseQuery design, the query callback must always
* return a valid query, collection, or config object.
*
* ❌ **This will cause a type error:**
* ```ts
* useLiveSuspenseQuery(
* (q) => userId ? q.from({ users }) : undefined // ❌ Error!
* )
* ```
*
* ✅ **Use conditional rendering instead:**
* ```ts
* function Profile({ userId }: { userId: string }) {
* const { data } = useLiveSuspenseQuery({
* query: (q) => q.from({ users }).where(({ users }) => eq(users.id, userId)),
* })
* return <div>{data.name}</div>
* }
*
* // In parent component:
* {userId ? <Profile userId={userId} /> : <div>No user</div>}
* ```
*
* ✅ **For optional inputs, conditionally render a component with complete query inputs:**
* ```ts
* {userId ? <Profile userId={userId} /> : <div>No user</div>}
* ```
*/
// Overload 1: Accept query function that always returns QueryBuilder
export function useLiveSuspenseQuery<TContext extends Context>(
queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
deps?: Array<unknown>,
): {
state: Map<string | number, GetResult<TContext>>
data: InferResultType<TContext>
collection: Collection<GetResult<TContext>, string | number, {}>
}
// Overload 2: Accept config object
export function useLiveSuspenseQuery<TContext extends Context>(
config: UseLiveQueryConfig<TContext>,
): {
state: Map<string | number, GetResult<TContext>>
data: InferResultType<TContext>
collection: Collection<GetResult<TContext>, string | number, {}>
}
// Overload 3: Accept legacy config object
export function useLiveSuspenseQuery<TContext extends Context>(
config: LiveQueryCollectionConfig<TContext>,
deps?: Array<unknown>,
): {
state: Map<string | number, GetResult<TContext>>
data: InferResultType<TContext>
collection: Collection<GetResult<TContext>, string | number, {}>
}
// Overload 4: Accept pre-created live query collection
export function useLiveSuspenseQuery<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
>(
liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
): {
state: Map<TKey, TResult>
data: Array<TResult>
collection: Collection<TResult, TKey, TUtils>
}
// Overload 5: Accept pre-created live query collection with singleResult: true
export function useLiveSuspenseQuery<
TResult extends object,
TKey extends string | number,
TUtils extends Record<string, any>,
>(
liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult,
): {
state: Map<TKey, TResult>
data: TResult | undefined
collection: Collection<TResult, TKey, TUtils> & SingleResult
}
// Implementation - uses useLiveQuery internally and adds Suspense logic
export function useLiveSuspenseQuery(
configOrQueryOrCollection: any,
deps?: Array<unknown>,
) {
const promiseRef = useRef<Promise<void> | null>(null)
const collectionRef = useRef<Collection<any, any, any> | null>(null)
const hasBeenReadyRef = useRef(false)
// Use useLiveQuery to handle collection management and reactivity
const result =
deps === undefined
? useLiveQueryForSuspense(configOrQueryOrCollection, undefined)
: useLiveQueryForSuspense(configOrQueryOrCollection, deps)
if (!result.isEnabled) {
// Suspense queries cannot be disabled - this matches TanStack Query's useSuspenseQuery behavior
throw new Error(
`useLiveSuspenseQuery does not support disabled queries (callback returned undefined/null). ` +
`The Suspense pattern requires data to always be defined (T, not T | undefined). ` +
`Solutions: ` +
`1) Use conditional rendering - don't render the component until the condition is met. ` +
`2) Use useLiveQuery instead, which supports disabled queries with the 'isEnabled' flag.`,
)
}
const queryInfo = getLiveQueryResultInfo(result)
// Reset promise and ready state when query identity changes
if (collectionRef.current !== result.collection) {
promiseRef.current = null
collectionRef.current = result.collection
hasBeenReadyRef.current = false
}
// SUSPENSE LOGIC: Throw promise or error based on collection status
const collectionStatus = result.collection.status
// Track when we reach ready state
if (result.isReady || queryInfo.observer.isInitialRenderReady()) {
hasBeenReadyRef.current = true
promiseRef.current = null
clearInitialRenderError(result.collection, queryInfo.client)
}
const observerError = queryInfo.observer.getError()
// A client request can reject with undefined, which getError() cannot
// distinguish from no error. The request status preserves that distinction.
const clientQuery =
queryInfo.client && queryInfo.queryHash
? queryInfo.client._getLiveQuery(queryInfo.queryHash)
: undefined
// A configured persisted restore may still satisfy this render after the
// client query fails. Let the observer classify that failure below.
if (
!hasBeenReadyRef.current &&
(result.persistedStatus === `unavailable` ||
result.persistedStatus === `error`) &&
(observerError !== undefined || clientQuery?.status === `error`)
) {
promiseRef.current = null
throw observerError === undefined ? clientQuery?.error : observerError
}
const errorEntry = initialRenderErrors.get(result.collection)
const initialRenderError = queryInfo.client
? errorEntry?.byClient.get(queryInfo.client)
: errorEntry?.unscoped
// A replacement client query must not inherit the prior request's error.
if (
initialRenderError?.clientQuery !== undefined &&
initialRenderError.clientQuery !== clientQuery
) {
clearInitialRenderError(result.collection, queryInfo.client)
} else if (initialRenderError && !hasBeenReadyRef.current) {
promiseRef.current = null
throw initialRenderError.error
}
// Only throw errors during initial load (before first ready)
// After success, errors surface as stale data (matches TanStack Query behavior)
if (
collectionStatus === `error` &&
!hasBeenReadyRef.current &&
(result.persistedStatus === `unavailable` ||
result.persistedStatus === `error`)
) {
promiseRef.current = null
// TODO: Once collections hold a reference to their last error object (#671),
// we should rethrow that actual error instead of creating a generic message
throw new Error(`Collection "${result.collection.id}" failed to load`)
}
if (
!hasBeenReadyRef.current &&
(result.isLoading ||
result.isIdle ||
result.isError ||
(collectionStatus === `error` &&
result.persistedStatus !== `unavailable`))
) {
if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) {
const reason = queryInfo.identityError
? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}`
: `the query has no stable identity`
throw new Error(
`Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`,
)
}
// Create or reuse promise for current collection
if (!promiseRef.current) {
const collection = result.collection
const client = queryInfo.client
const queryHash = queryInfo.queryHash
let active = true
const stopWatchingCleanup = collection.once(`status:cleaned-up`, () => {
active = false
})
let preload: Promise<void>
try {
preload = queryInfo.observer.preloadForInitialRender()
} catch (error) {
stopWatchingCleanup()
throw error
}
const preloadClientQuery =
client && queryHash ? client._getLiveQuery(queryHash) : undefined
promiseRef.current = preload
.catch((error: unknown) => {
const latestClientQuery =
client && queryHash ? client._getLiveQuery(queryHash) : undefined
if (active && latestClientQuery === preloadClientQuery)
rememberInitialRenderError(
collection,
client,
error,
preloadClientQuery,
)
throw error
})
.finally(stopWatchingCleanup)
}
// React Suspense catches this promise and retries after preload settles.
throw promiseRef.current
}
// Return data without status/loading flags (handled by Suspense/ErrorBoundary)
// If error after success, return last known good state (stale data)
return {
state: result.state,
data: result.data,
collection: result.collection,
}
}