@tanstack/react-db 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/cjs/DbProvider.cjs +23 -0
- package/dist/cjs/DbProvider.cjs.map +1 -0
- package/dist/cjs/DbProvider.d.cts +9 -0
- package/dist/cjs/HydrationBoundary.cjs +16 -0
- package/dist/cjs/HydrationBoundary.cjs.map +1 -0
- package/dist/cjs/HydrationBoundary.d.cts +7 -0
- package/dist/cjs/index.cjs +6 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +4 -1
- package/dist/cjs/live-query-internals.cjs +19 -0
- package/dist/cjs/live-query-internals.cjs.map +1 -0
- package/dist/cjs/live-query-internals.d.cts +9 -0
- package/dist/cjs/useLiveInfiniteQuery.cjs +76 -8
- package/dist/cjs/useLiveInfiniteQuery.cjs.map +1 -1
- package/dist/cjs/useLiveInfiniteQuery.d.cts +12 -58
- package/dist/cjs/useLiveQuery.cjs +263 -41
- package/dist/cjs/useLiveQuery.cjs.map +1 -1
- package/dist/cjs/useLiveQuery.d.cts +79 -33
- package/dist/cjs/useLiveSuspenseQuery.cjs +19 -5
- package/dist/cjs/useLiveSuspenseQuery.cjs.map +1 -1
- package/dist/cjs/useLiveSuspenseQuery.d.cts +22 -19
- package/dist/esm/DbProvider.d.ts +9 -0
- package/dist/esm/DbProvider.js +23 -0
- package/dist/esm/DbProvider.js.map +1 -0
- package/dist/esm/HydrationBoundary.d.ts +7 -0
- package/dist/esm/HydrationBoundary.js +16 -0
- package/dist/esm/HydrationBoundary.js.map +1 -0
- package/dist/esm/index.d.ts +4 -1
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/live-query-internals.d.ts +9 -0
- package/dist/esm/live-query-internals.js +19 -0
- package/dist/esm/live-query-internals.js.map +1 -0
- package/dist/esm/useLiveInfiniteQuery.d.ts +12 -58
- package/dist/esm/useLiveInfiniteQuery.js +76 -8
- package/dist/esm/useLiveInfiniteQuery.js.map +1 -1
- package/dist/esm/useLiveQuery.d.ts +79 -33
- package/dist/esm/useLiveQuery.js +265 -43
- package/dist/esm/useLiveQuery.js.map +1 -1
- package/dist/esm/useLiveSuspenseQuery.d.ts +22 -19
- package/dist/esm/useLiveSuspenseQuery.js +19 -5
- package/dist/esm/useLiveSuspenseQuery.js.map +1 -1
- package/package.json +2 -2
- package/skills/react-db/SKILL.md +119 -90
- package/src/DbProvider.tsx +32 -0
- package/src/HydrationBoundary.tsx +25 -0
- package/src/index.ts +8 -1
- package/src/live-query-internals.ts +36 -0
- package/src/useLiveInfiniteQuery.ts +119 -65
- package/src/useLiveQuery.ts +441 -92
- package/src/useLiveSuspenseQuery.ts +56 -29
|
@@ -1,19 +1,21 @@
|
|
|
1
|
+
import { UseLiveQueryConfig } from './useLiveQuery.js';
|
|
1
2
|
import { Collection, Context, GetResult, InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, NonSingleResult, QueryBuilder, SingleResult } from '@tanstack/db';
|
|
2
3
|
/**
|
|
3
4
|
* Create a live query with React Suspense support
|
|
4
5
|
* @param queryFn - Query function that defines what data to fetch
|
|
5
|
-
* @param deps -
|
|
6
|
+
* @param deps - Deprecated array of dependencies that trigger query re-execution when changed
|
|
6
7
|
* @returns Object with reactive data and state - data is guaranteed to be defined
|
|
7
8
|
* @throws Promise when data is loading (caught by Suspense boundary)
|
|
8
9
|
* @throws Error when collection fails (caught by Error boundary)
|
|
9
10
|
* @example
|
|
10
11
|
* // Basic usage with Suspense
|
|
11
12
|
* function TodoList() {
|
|
12
|
-
* const { data } = useLiveSuspenseQuery(
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* const { data } = useLiveSuspenseQuery({
|
|
14
|
+
* query: (q) =>
|
|
15
|
+
* q.from({ todos: todosCollection })
|
|
16
|
+
* .where(({ todos }) => eq(todos.completed, false))
|
|
17
|
+
* .select(({ todos }) => ({ id: todos.id, text: todos.text }))
|
|
18
|
+
* })
|
|
17
19
|
*
|
|
18
20
|
* return (
|
|
19
21
|
* <ul>
|
|
@@ -40,12 +42,11 @@ import { Collection, Context, GetResult, InferResultType, InitialQueryBuilder, L
|
|
|
40
42
|
* // data is guaranteed to be the single item (or undefined if not found)
|
|
41
43
|
*
|
|
42
44
|
* @example
|
|
43
|
-
* //
|
|
44
|
-
* const { data } = useLiveSuspenseQuery(
|
|
45
|
-
* (q) => q.from({ todos: todosCollection })
|
|
45
|
+
* // Structured captured values are included in derived query identity and trigger re-suspension
|
|
46
|
+
* const { data } = useLiveSuspenseQuery({
|
|
47
|
+
* query: (q) => q.from({ todos: todosCollection })
|
|
46
48
|
* .where(({ todos }) => gt(todos.priority, minPriority)),
|
|
47
|
-
*
|
|
48
|
-
* )
|
|
49
|
+
* })
|
|
49
50
|
*
|
|
50
51
|
* @example
|
|
51
52
|
* // With Error boundary
|
|
@@ -74,9 +75,9 @@ import { Collection, Context, GetResult, InferResultType, InitialQueryBuilder, L
|
|
|
74
75
|
* ✅ **Use conditional rendering instead:**
|
|
75
76
|
* ```ts
|
|
76
77
|
* function Profile({ userId }: { userId: string }) {
|
|
77
|
-
* const { data } = useLiveSuspenseQuery(
|
|
78
|
-
* (q) => q.from({ users }).where(({ users }) => eq(users.id, userId))
|
|
79
|
-
* )
|
|
78
|
+
* const { data } = useLiveSuspenseQuery({
|
|
79
|
+
* query: (q) => q.from({ users }).where(({ users }) => eq(users.id, userId)),
|
|
80
|
+
* })
|
|
80
81
|
* return <div>{data.name}</div>
|
|
81
82
|
* }
|
|
82
83
|
*
|
|
@@ -84,12 +85,9 @@ import { Collection, Context, GetResult, InferResultType, InitialQueryBuilder, L
|
|
|
84
85
|
* {userId ? <Profile userId={userId} /> : <div>No user</div>}
|
|
85
86
|
* ```
|
|
86
87
|
*
|
|
87
|
-
* ✅ **
|
|
88
|
+
* ✅ **For optional inputs, conditionally render a component with complete query inputs:**
|
|
88
89
|
* ```ts
|
|
89
|
-
*
|
|
90
|
-
* (q) => userId ? q.from({ users }) : undefined, // ✅ Supported!
|
|
91
|
-
* [userId]
|
|
92
|
-
* )
|
|
90
|
+
* {userId ? <Profile userId={userId} /> : <div>No user</div>}
|
|
93
91
|
* ```
|
|
94
92
|
*/
|
|
95
93
|
export declare function useLiveSuspenseQuery<TContext extends Context>(queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>, deps?: Array<unknown>): {
|
|
@@ -97,6 +95,11 @@ export declare function useLiveSuspenseQuery<TContext extends Context>(queryFn:
|
|
|
97
95
|
data: InferResultType<TContext>;
|
|
98
96
|
collection: Collection<GetResult<TContext>, string | number, {}>;
|
|
99
97
|
};
|
|
98
|
+
export declare function useLiveSuspenseQuery<TContext extends Context>(config: UseLiveQueryConfig<TContext>): {
|
|
99
|
+
state: Map<string | number, GetResult<TContext>>;
|
|
100
|
+
data: InferResultType<TContext>;
|
|
101
|
+
collection: Collection<GetResult<TContext>, string | number, {}>;
|
|
102
|
+
};
|
|
100
103
|
export declare function useLiveSuspenseQuery<TContext extends Context>(config: LiveQueryCollectionConfig<TContext>, deps?: Array<unknown>): {
|
|
101
104
|
state: Map<string | number, GetResult<TContext>>;
|
|
102
105
|
data: InferResultType<TContext>;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { useRef } from "react";
|
|
2
3
|
import { useLiveQuery } from "./useLiveQuery.js";
|
|
3
|
-
|
|
4
|
+
import { getLiveQueryResultInfo } from "./live-query-internals.js";
|
|
5
|
+
function useLiveSuspenseQuery(configOrQueryOrCollection, deps) {
|
|
4
6
|
const promiseRef = useRef(null);
|
|
5
7
|
const collectionRef = useRef(null);
|
|
6
8
|
const hasBeenReadyRef = useRef(false);
|
|
7
|
-
const result = useLiveQuery(configOrQueryOrCollection, deps);
|
|
9
|
+
const result = deps === void 0 ? useLiveQuery(configOrQueryOrCollection) : useLiveQuery(configOrQueryOrCollection, deps);
|
|
10
|
+
const queryInfo = getLiveQueryResultInfo(result);
|
|
8
11
|
if (collectionRef.current !== result.collection) {
|
|
9
12
|
promiseRef.current = null;
|
|
10
13
|
collectionRef.current = result.collection;
|
|
@@ -16,17 +19,28 @@ function useLiveSuspenseQuery(configOrQueryOrCollection, deps = []) {
|
|
|
16
19
|
);
|
|
17
20
|
}
|
|
18
21
|
const collectionStatus = result.collection.status;
|
|
19
|
-
if (
|
|
22
|
+
if (result.isReady) {
|
|
20
23
|
hasBeenReadyRef.current = true;
|
|
21
24
|
promiseRef.current = null;
|
|
22
25
|
}
|
|
26
|
+
const observerError = queryInfo.observer.getError();
|
|
27
|
+
if (observerError !== void 0 && !hasBeenReadyRef.current) {
|
|
28
|
+
promiseRef.current = null;
|
|
29
|
+
throw observerError;
|
|
30
|
+
}
|
|
23
31
|
if (collectionStatus === `error` && !hasBeenReadyRef.current) {
|
|
24
32
|
promiseRef.current = null;
|
|
25
33
|
throw new Error(`Collection "${result.collection.id}" failed to load`);
|
|
26
34
|
}
|
|
27
|
-
if (
|
|
35
|
+
if (!hasBeenReadyRef.current && (result.isLoading || result.isIdle)) {
|
|
36
|
+
if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) {
|
|
37
|
+
const reason = queryInfo.identityError ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}` : `the query has no stable identity`;
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
28
42
|
if (!promiseRef.current) {
|
|
29
|
-
promiseRef.current =
|
|
43
|
+
promiseRef.current = queryInfo.observer.preload();
|
|
30
44
|
}
|
|
31
45
|
throw promiseRef.current;
|
|
32
46
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useLiveSuspenseQuery.js","sources":["../../src/useLiveSuspenseQuery.ts"],"sourcesContent":["
|
|
1
|
+
{"version":3,"file":"useLiveSuspenseQuery.js","sources":["../../src/useLiveSuspenseQuery.ts"],"sourcesContent":["'use client'\n\nimport { useRef } from 'react'\nimport { useLiveQuery } from './useLiveQuery'\nimport { getLiveQueryResultInfo } from './live-query-internals'\nimport type { UseLiveQueryConfig } from './useLiveQuery'\nimport type {\n Collection,\n Context,\n GetResult,\n InferResultType,\n InitialQueryBuilder,\n LiveQueryCollectionConfig,\n NonSingleResult,\n QueryBuilder,\n SingleResult,\n} from '@tanstack/db'\n\n/**\n * Create a live query with React Suspense support\n * @param queryFn - Query function that defines what data to fetch\n * @param deps - Deprecated array of dependencies that trigger query re-execution when changed\n * @returns Object with reactive data and state - data is guaranteed to be defined\n * @throws Promise when data is loading (caught by Suspense boundary)\n * @throws Error when collection fails (caught by Error boundary)\n * @example\n * // Basic usage with Suspense\n * function TodoList() {\n * const { data } = useLiveSuspenseQuery({\n * query: (q) =>\n * q.from({ todos: todosCollection })\n * .where(({ todos }) => eq(todos.completed, false))\n * .select(({ todos }) => ({ id: todos.id, text: todos.text }))\n * })\n *\n * return (\n * <ul>\n * {data.map(todo => <li key={todo.id}>{todo.text}</li>)}\n * </ul>\n * )\n * }\n *\n * function App() {\n * return (\n * <Suspense fallback={<div>Loading...</div>}>\n * <TodoList />\n * </Suspense>\n * )\n * }\n *\n * @example\n * // Single result query\n * const { data } = useLiveSuspenseQuery(\n * (q) => q.from({ todos: todosCollection })\n * .where(({ todos }) => eq(todos.id, 1))\n * .findOne()\n * )\n * // data is guaranteed to be the single item (or undefined if not found)\n *\n * @example\n * // Structured captured values are included in derived query identity and trigger re-suspension\n * const { data } = useLiveSuspenseQuery({\n * query: (q) => q.from({ todos: todosCollection })\n * .where(({ todos }) => gt(todos.priority, minPriority)),\n * })\n *\n * @example\n * // With Error boundary\n * function App() {\n * return (\n * <ErrorBoundary fallback={<div>Error loading data</div>}>\n * <Suspense fallback={<div>Loading...</div>}>\n * <TodoList />\n * </Suspense>\n * </ErrorBoundary>\n * )\n * }\n *\n * @remarks\n * **Important:** This hook does NOT support disabled queries (returning undefined/null).\n * Following TanStack Query's useSuspenseQuery design, the query callback must always\n * return a valid query, collection, or config object.\n *\n * ❌ **This will cause a type error:**\n * ```ts\n * useLiveSuspenseQuery(\n * (q) => userId ? q.from({ users }) : undefined // ❌ Error!\n * )\n * ```\n *\n * ✅ **Use conditional rendering instead:**\n * ```ts\n * function Profile({ userId }: { userId: string }) {\n * const { data } = useLiveSuspenseQuery({\n * query: (q) => q.from({ users }).where(({ users }) => eq(users.id, userId)),\n * })\n * return <div>{data.name}</div>\n * }\n *\n * // In parent component:\n * {userId ? <Profile userId={userId} /> : <div>No user</div>}\n * ```\n *\n * ✅ **For optional inputs, conditionally render a component with complete query inputs:**\n * ```ts\n * {userId ? <Profile userId={userId} /> : <div>No user</div>}\n * ```\n */\n// Overload 1: Accept query function that always returns QueryBuilder\nexport function useLiveSuspenseQuery<TContext extends Context>(\n queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,\n deps?: Array<unknown>,\n): {\n state: Map<string | number, GetResult<TContext>>\n data: InferResultType<TContext>\n collection: Collection<GetResult<TContext>, string | number, {}>\n}\n\n// Overload 2: Accept config object\nexport function useLiveSuspenseQuery<TContext extends Context>(\n config: UseLiveQueryConfig<TContext>,\n): {\n state: Map<string | number, GetResult<TContext>>\n data: InferResultType<TContext>\n collection: Collection<GetResult<TContext>, string | number, {}>\n}\n\n// Overload 3: Accept legacy config object\nexport function useLiveSuspenseQuery<TContext extends Context>(\n config: LiveQueryCollectionConfig<TContext>,\n deps?: Array<unknown>,\n): {\n state: Map<string | number, GetResult<TContext>>\n data: InferResultType<TContext>\n collection: Collection<GetResult<TContext>, string | number, {}>\n}\n\n// Overload 4: Accept pre-created live query collection\nexport function useLiveSuspenseQuery<\n TResult extends object,\n TKey extends string | number,\n TUtils extends Record<string, any>,\n>(\n liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,\n): {\n state: Map<TKey, TResult>\n data: Array<TResult>\n collection: Collection<TResult, TKey, TUtils>\n}\n\n// Overload 5: Accept pre-created live query collection with singleResult: true\nexport function useLiveSuspenseQuery<\n TResult extends object,\n TKey extends string | number,\n TUtils extends Record<string, any>,\n>(\n liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult,\n): {\n state: Map<TKey, TResult>\n data: TResult | undefined\n collection: Collection<TResult, TKey, TUtils> & SingleResult\n}\n\n// Implementation - uses useLiveQuery internally and adds Suspense logic\nexport function useLiveSuspenseQuery(\n configOrQueryOrCollection: any,\n deps?: Array<unknown>,\n) {\n const promiseRef = useRef<Promise<void> | null>(null)\n const collectionRef = useRef<Collection<any, any, any> | null>(null)\n const hasBeenReadyRef = useRef(false)\n\n // Use useLiveQuery to handle collection management and reactivity\n const result =\n deps === undefined\n ? useLiveQuery(configOrQueryOrCollection)\n : useLiveQuery(configOrQueryOrCollection, deps)\n const queryInfo = getLiveQueryResultInfo(result)\n\n // Reset promise and ready state when query identity changes\n if (collectionRef.current !== result.collection) {\n promiseRef.current = null\n collectionRef.current = result.collection\n hasBeenReadyRef.current = false\n }\n\n // SUSPENSE LOGIC: Throw promise or error based on collection status\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (!result.isEnabled) {\n // Suspense queries cannot be disabled - this matches TanStack Query's useSuspenseQuery behavior\n throw new Error(\n `useLiveSuspenseQuery does not support disabled queries (callback returned undefined/null). ` +\n `The Suspense pattern requires data to always be defined (T, not T | undefined). ` +\n `Solutions: ` +\n `1) Use conditional rendering - don't render the component until the condition is met. ` +\n `2) Use useLiveQuery instead, which supports disabled queries with the 'isEnabled' flag.`,\n )\n }\n\n const collectionStatus = result.collection.status\n\n // Track when we reach ready state\n if (result.isReady) {\n hasBeenReadyRef.current = true\n promiseRef.current = null\n }\n\n const observerError = queryInfo.observer.getError()\n if (observerError !== undefined && !hasBeenReadyRef.current) {\n promiseRef.current = null\n throw observerError\n }\n\n // Only throw errors during initial load (before first ready)\n // After success, errors surface as stale data (matches TanStack Query behavior)\n if (collectionStatus === `error` && !hasBeenReadyRef.current) {\n promiseRef.current = null\n // TODO: Once collections hold a reference to their last error object (#671),\n // we should rethrow that actual error instead of creating a generic message\n throw new Error(`Collection \"${result.collection.id}\" failed to load`)\n }\n\n if (!hasBeenReadyRef.current && (result.isLoading || result.isIdle)) {\n if (queryInfo.client?._isSsrStreamingEnabled() && !queryInfo.queryHash) {\n const reason = queryInfo.identityError\n ? `${queryInfo.identityError.reason} at ${queryInfo.identityError.path}`\n : `the query has no stable identity`\n throw new Error(\n `Cannot stream this live query during SSR because ${reason}. Provide an explicit serializable queryKey.`,\n )\n }\n\n // Create or reuse promise for current collection\n if (!promiseRef.current) {\n promiseRef.current = queryInfo.observer.preload()\n }\n // THROW PROMISE - React Suspense catches this (React 18+ required)\n // Note: We don't check React version here. In React <18, this will be caught\n // by an Error Boundary, which provides a reasonable failure mode.\n throw promiseRef.current\n }\n\n // Return data without status/loading flags (handled by Suspense/ErrorBoundary)\n // If error after success, return last known good state (stale data)\n return {\n state: result.state,\n data: result.data,\n collection: result.collection,\n }\n}\n"],"names":[],"mappings":";;;;AAoKO;AAIL;AACA;AACA;AAGA;AAIA;AAGA;AACE;AACA;AACA;AAA0B;AAK5B;AAEE;AAAU;AACR;AAAA;AAQJ;AAGA;AACE;AACA;AAAqB;AAGvB;AACA;AACE;AACA;AAAM;AAKR;AACE;AAGA;AAAqE;AAGvE;AACE;AACE;AAGA;AAAU;AACkD;AAAA;AAK9D;AACE;AAAwC;AAK1C;AAAiB;AAKnB;AAAO;AACS;AACD;AACM;AAEvB;;;;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/react-db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "React integration for @tanstack/db",
|
|
5
5
|
"author": "Kyle Mathews",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"use-sync-external-store": "^1.6.0",
|
|
44
|
-
"@tanstack/db": "0.
|
|
44
|
+
"@tanstack/db": "0.8.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"react": ">=16.8.0"
|
package/skills/react-db/SKILL.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: react-db
|
|
3
3
|
description: >
|
|
4
|
-
React bindings for TanStack DB. useLiveQuery
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
React bindings for TanStack DB. Prefer useLiveQuery({ query }) with
|
|
5
|
+
derived structured query identity. Provide queryKey only for opaque
|
|
6
|
+
functional query variants or very hot render paths. Dependency arrays are
|
|
7
|
+
legacy and warn before 1.0 removal. useLiveSuspenseQuery for
|
|
7
8
|
React Suspense with Error Boundaries (data always defined).
|
|
8
9
|
useLiveInfiniteQuery for cursor-based pagination (pageSize, fetchNextPage,
|
|
9
10
|
hasNextPage, isFetchingNextPage). usePacedMutations for debounced React
|
|
@@ -13,7 +14,7 @@ description: >
|
|
|
13
14
|
type: framework
|
|
14
15
|
library: db
|
|
15
16
|
framework: react
|
|
16
|
-
library_version: '0.6.
|
|
17
|
+
library_version: '0.6.17'
|
|
17
18
|
requires:
|
|
18
19
|
- db-core
|
|
19
20
|
sources:
|
|
@@ -30,15 +31,16 @@ This skill builds on db-core. Read it first for collection setup, query builder,
|
|
|
30
31
|
## Setup
|
|
31
32
|
|
|
32
33
|
```tsx
|
|
33
|
-
import {
|
|
34
|
+
import { eq, not, useLiveQuery } from '@tanstack/react-db'
|
|
34
35
|
|
|
35
36
|
function TodoList() {
|
|
36
|
-
const { data: todos, isLoading } = useLiveQuery(
|
|
37
|
-
q
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
const { data: todos, isLoading } = useLiveQuery({
|
|
38
|
+
query: (q) =>
|
|
39
|
+
q
|
|
40
|
+
.from({ todo: todoCollection })
|
|
41
|
+
.where(({ todo }) => not(todo.completed))
|
|
42
|
+
.orderBy(({ todo }) => todo.created_at, 'asc'),
|
|
43
|
+
})
|
|
42
44
|
|
|
43
45
|
if (isLoading) return <div>Loading...</div>
|
|
44
46
|
|
|
@@ -59,7 +61,7 @@ function TodoList() {
|
|
|
59
61
|
### useLiveQuery
|
|
60
62
|
|
|
61
63
|
```tsx
|
|
62
|
-
//
|
|
64
|
+
// Preferred config object with derived query identity
|
|
63
65
|
const {
|
|
64
66
|
data,
|
|
65
67
|
state,
|
|
@@ -70,15 +72,14 @@ const {
|
|
|
70
72
|
isError,
|
|
71
73
|
isIdle,
|
|
72
74
|
isCleanedUp,
|
|
73
|
-
} = useLiveQuery(
|
|
74
|
-
(q) =>
|
|
75
|
+
} = useLiveQuery({
|
|
76
|
+
query: (q) =>
|
|
75
77
|
q
|
|
76
78
|
.from({ todo: todoCollection })
|
|
77
79
|
.where(({ todo }) => eq(todo.userId, userId)),
|
|
78
|
-
|
|
79
|
-
)
|
|
80
|
+
})
|
|
80
81
|
|
|
81
|
-
//
|
|
82
|
+
// Static query
|
|
82
83
|
const { data } = useLiveQuery({
|
|
83
84
|
query: (q) => q.from({ todo: todoCollection }),
|
|
84
85
|
gcTime: 60000,
|
|
@@ -87,16 +88,13 @@ const { data } = useLiveQuery({
|
|
|
87
88
|
// Pre-created collection (from route loader)
|
|
88
89
|
const { data } = useLiveQuery(preloadedCollection)
|
|
89
90
|
|
|
90
|
-
// Conditional query —
|
|
91
|
-
const { data, status } = useLiveQuery(
|
|
92
|
-
(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
},
|
|
98
|
-
[userId],
|
|
99
|
-
)
|
|
91
|
+
// Conditional query — derived identity handles enabled/disabled transitions
|
|
92
|
+
const { data, status } = useLiveQuery((q) => {
|
|
93
|
+
if (!userId) return undefined
|
|
94
|
+
return q
|
|
95
|
+
.from({ todo: todoCollection })
|
|
96
|
+
.where(({ todo }) => eq(todo.userId, userId))
|
|
97
|
+
})
|
|
100
98
|
// When disabled: status='disabled', data=undefined
|
|
101
99
|
```
|
|
102
100
|
|
|
@@ -106,9 +104,9 @@ const { data, status } = useLiveQuery(
|
|
|
106
104
|
// data is ALWAYS defined — never undefined
|
|
107
105
|
// Must wrap in <Suspense> and <ErrorBoundary>
|
|
108
106
|
function TodoList() {
|
|
109
|
-
const { data: todos } = useLiveSuspenseQuery(
|
|
110
|
-
q.from({ todo: todoCollection }),
|
|
111
|
-
)
|
|
107
|
+
const { data: todos } = useLiveSuspenseQuery({
|
|
108
|
+
query: (q) => q.from({ todo: todoCollection }),
|
|
109
|
+
})
|
|
112
110
|
|
|
113
111
|
return (
|
|
114
112
|
<ul>
|
|
@@ -119,14 +117,13 @@ function TodoList() {
|
|
|
119
117
|
)
|
|
120
118
|
}
|
|
121
119
|
|
|
122
|
-
//
|
|
123
|
-
const { data } = useLiveSuspenseQuery(
|
|
124
|
-
(q) =>
|
|
120
|
+
// Structured captured values are part of the derived identity and re-suspend when changed
|
|
121
|
+
const { data } = useLiveSuspenseQuery({
|
|
122
|
+
query: (q) =>
|
|
125
123
|
q
|
|
126
124
|
.from({ todo: todoCollection })
|
|
127
125
|
.where(({ todo }) => eq(todo.category, category)),
|
|
128
|
-
|
|
129
|
-
)
|
|
126
|
+
})
|
|
130
127
|
```
|
|
131
128
|
|
|
132
129
|
### useLiveInfiniteQuery
|
|
@@ -137,9 +134,11 @@ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
|
|
137
134
|
(q) =>
|
|
138
135
|
q
|
|
139
136
|
.from({ posts: postsCollection })
|
|
137
|
+
.where(({ posts }) => eq(posts.category, category))
|
|
140
138
|
.orderBy(({ posts }) => posts.createdAt, 'desc'),
|
|
141
|
-
{
|
|
142
|
-
|
|
139
|
+
{
|
|
140
|
+
pageSize: 20,
|
|
141
|
+
},
|
|
143
142
|
)
|
|
144
143
|
|
|
145
144
|
// data is the flat array of all loaded pages
|
|
@@ -174,16 +173,17 @@ When a query uses includes (subqueries in `select`), each child field is a live
|
|
|
174
173
|
|
|
175
174
|
```tsx
|
|
176
175
|
function ProjectList() {
|
|
177
|
-
const { data: projects } = useLiveQuery(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
176
|
+
const { data: projects } = useLiveQuery({
|
|
177
|
+
query: (q) =>
|
|
178
|
+
q.from({ p: projectsCollection }).select(({ p }) => ({
|
|
179
|
+
id: p.id,
|
|
180
|
+
name: p.name,
|
|
181
|
+
issues: q
|
|
182
|
+
.from({ i: issuesCollection })
|
|
183
|
+
.where(({ i }) => eq(i.projectId, p.id))
|
|
184
|
+
.select(({ i }) => ({ id: i.id, title: i.title })),
|
|
185
|
+
})),
|
|
186
|
+
})
|
|
187
187
|
|
|
188
188
|
return (
|
|
189
189
|
<ul>
|
|
@@ -217,19 +217,20 @@ With `toArray()`, child results are plain arrays and the parent re-renders on ch
|
|
|
217
217
|
```tsx
|
|
218
218
|
import { toArray, eq } from '@tanstack/react-db'
|
|
219
219
|
|
|
220
|
-
const { data: projects } = useLiveQuery(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
)
|
|
232
|
-
|
|
220
|
+
const { data: projects } = useLiveQuery({
|
|
221
|
+
query: (q) =>
|
|
222
|
+
q.from({ p: projectsCollection }).select(({ p }) => ({
|
|
223
|
+
id: p.id,
|
|
224
|
+
name: p.name,
|
|
225
|
+
issues: toArray(
|
|
226
|
+
q
|
|
227
|
+
.from({ i: issuesCollection })
|
|
228
|
+
.where(({ i }) => eq(i.projectId, p.id))
|
|
229
|
+
.select(({ i }) => ({ id: i.id, title: i.title })),
|
|
230
|
+
),
|
|
231
|
+
})),
|
|
232
|
+
})
|
|
233
|
+
// project.issues is Array<{ id: string; title: string }> — no subcomponent needed
|
|
233
234
|
```
|
|
234
235
|
|
|
235
236
|
See db-core/live-queries/SKILL.md for full includes rules (correlation conditions, nested includes, aggregates).
|
|
@@ -238,7 +239,8 @@ See db-core/live-queries/SKILL.md for full includes rules (correlation condition
|
|
|
238
239
|
|
|
239
240
|
Live query results include computed, read-only virtual properties on every row:
|
|
240
241
|
|
|
241
|
-
- `$synced`: `true` when
|
|
242
|
+
- `$synced`: `true` when no pending local optimistic write affects the row;
|
|
243
|
+
`false` while one does. It does not prove backend confirmation.
|
|
242
244
|
- `$origin`: `"local"` if the last confirmed change came from this client, otherwise `"remote"`.
|
|
243
245
|
- `$key`: the row key for the result.
|
|
244
246
|
- `$collectionId`: the source collection ID.
|
|
@@ -246,38 +248,54 @@ Live query results include computed, read-only virtual properties on every row:
|
|
|
246
248
|
These props are added automatically and can be used in `where`, `select`, and `orderBy` clauses. Do not persist them back to storage.
|
|
247
249
|
|
|
248
250
|
```tsx
|
|
249
|
-
const { data } = useLiveQuery(
|
|
250
|
-
(q) =>
|
|
251
|
+
const { data } = useLiveQuery({
|
|
252
|
+
query: (q) =>
|
|
251
253
|
q
|
|
252
254
|
.from({ todo: todoCollection })
|
|
253
255
|
.where(({ todo }) => eq(todo.$synced, false)),
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// Shows only optimistic (unconfirmed) todos
|
|
256
|
+
})
|
|
257
|
+
// Shows rows with pending local optimistic writes
|
|
257
258
|
```
|
|
258
259
|
|
|
259
260
|
## React-Specific Patterns
|
|
260
261
|
|
|
261
|
-
###
|
|
262
|
+
### Query identity
|
|
262
263
|
|
|
263
264
|
```tsx
|
|
264
|
-
//
|
|
265
|
-
const { data } = useLiveQuery(
|
|
266
|
-
(q) =>
|
|
265
|
+
// Structured captured values are included in the derived identity
|
|
266
|
+
const { data } = useLiveQuery({
|
|
267
|
+
query: (q) =>
|
|
267
268
|
q
|
|
268
269
|
.from({ todo: todoCollection })
|
|
269
270
|
.where(({ todo }) =>
|
|
270
271
|
and(eq(todo.userId, userId), eq(todo.status, filter)),
|
|
271
272
|
),
|
|
272
|
-
|
|
273
|
-
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// Static query
|
|
276
|
+
const { data } = useLiveQuery({
|
|
277
|
+
query: (q) => q.from({ todo: todoCollection }),
|
|
278
|
+
})
|
|
279
|
+
```
|
|
274
280
|
|
|
275
|
-
|
|
276
|
-
|
|
281
|
+
Use `queryKey` only when DB cannot derive identity from structured IR, such as
|
|
282
|
+
`.fn.where`, `.fn.select`, `.fn.having`, or as a deliberate performance escape
|
|
283
|
+
hatch on a hot render path:
|
|
277
284
|
|
|
278
|
-
|
|
285
|
+
```tsx
|
|
286
|
+
const { data } = useLiveQuery({
|
|
287
|
+
queryKey: [todoCollection.id, 'search', search],
|
|
288
|
+
query: (q) =>
|
|
289
|
+
q.from({ todo: todoCollection }).fn.where(({ todo }) => {
|
|
290
|
+
return fuzzyMatch(todo.title, search)
|
|
291
|
+
}),
|
|
292
|
+
})
|
|
279
293
|
```
|
|
280
294
|
|
|
295
|
+
Before 1.0, opaque IR warns and keeps legacy mount-stable identity. Slow or
|
|
296
|
+
repeated derived identity work also warns once. Both point to the same
|
|
297
|
+
`queryKey` escape hatch; unhashable IR without a key will throw in 1.0.
|
|
298
|
+
|
|
281
299
|
### Suspense + Error Boundary
|
|
282
300
|
|
|
283
301
|
```tsx
|
|
@@ -295,36 +313,42 @@ const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }), [])
|
|
|
295
313
|
await todoCollection.preload()
|
|
296
314
|
|
|
297
315
|
// In component — data available immediately:
|
|
298
|
-
const { data } = useLiveQuery(
|
|
316
|
+
const { data } = useLiveQuery({
|
|
317
|
+
query: (q) => q.from({ todo: todoCollection }),
|
|
318
|
+
})
|
|
299
319
|
```
|
|
300
320
|
|
|
301
321
|
See meta-framework/SKILL.md for full preloading patterns.
|
|
302
322
|
|
|
303
323
|
## Common Mistakes
|
|
304
324
|
|
|
305
|
-
### CRITICAL
|
|
325
|
+
### CRITICAL Using opaque query logic without queryKey
|
|
306
326
|
|
|
307
327
|
Wrong:
|
|
308
328
|
|
|
309
329
|
```tsx
|
|
310
|
-
const { data } = useLiveQuery(
|
|
311
|
-
|
|
312
|
-
)
|
|
330
|
+
const { data } = useLiveQuery({
|
|
331
|
+
query: (q) =>
|
|
332
|
+
q.from({ todo: todoCollection }).fn.where(({ todo }) => {
|
|
333
|
+
return fuzzyMatch(todo.title, search)
|
|
334
|
+
}),
|
|
335
|
+
})
|
|
313
336
|
```
|
|
314
337
|
|
|
315
338
|
Correct:
|
|
316
339
|
|
|
317
340
|
```tsx
|
|
318
|
-
const { data } = useLiveQuery(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
)
|
|
341
|
+
const { data } = useLiveQuery({
|
|
342
|
+
queryKey: [todoCollection.id, 'search', search],
|
|
343
|
+
query: (q) =>
|
|
344
|
+
q.from({ todo: todoCollection }).fn.where(({ todo }) => {
|
|
345
|
+
return fuzzyMatch(todo.title, search)
|
|
346
|
+
}),
|
|
347
|
+
})
|
|
325
348
|
```
|
|
326
349
|
|
|
327
|
-
|
|
350
|
+
Structured expressions are hashable by default. Functional query variants are
|
|
351
|
+
opaque runtime code, so they need an explicit key to say when identity changes.
|
|
328
352
|
|
|
329
353
|
Source: docs/framework/react/overview.md
|
|
330
354
|
|
|
@@ -354,7 +378,12 @@ Source: docs/guides/live-queries.md
|
|
|
354
378
|
|
|
355
379
|
### HIGH "Not a Collection" error from duplicate @tanstack/db
|
|
356
380
|
|
|
357
|
-
If
|
|
381
|
+
If a query-builder alias throws
|
|
382
|
+
`InvalidSourceError: The value provided for alias "todo" is not a Collection`,
|
|
383
|
+
it can mean two copies of `@tanstack/db` are installed. Direct
|
|
384
|
+
`useLiveQuery(preCreatedCollection)` detection is structural and works across
|
|
385
|
+
package copies or realms, but `q.from({ todo: collection })` still validates
|
|
386
|
+
the source with the core collection class.
|
|
358
387
|
|
|
359
388
|
In dev mode, TanStack DB also throws `DuplicateDbInstanceError` if two instances are detected.
|
|
360
389
|
|
|
@@ -372,7 +401,7 @@ If multiple versions appear, fix with one of:
|
|
|
372
401
|
{
|
|
373
402
|
"pnpm": {
|
|
374
403
|
"overrides": {
|
|
375
|
-
"@tanstack/db": "^0.6.
|
|
404
|
+
"@tanstack/db": "^0.6.17"
|
|
376
405
|
}
|
|
377
406
|
}
|
|
378
407
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from 'react'
|
|
4
|
+
import type { DbClient } from '@tanstack/db'
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
|
|
7
|
+
const DbContext = createContext<DbClient | undefined>(undefined)
|
|
8
|
+
|
|
9
|
+
export type DbProviderProps = {
|
|
10
|
+
client: DbClient
|
|
11
|
+
children?: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DbProvider(props: DbProviderProps) {
|
|
15
|
+
return (
|
|
16
|
+
<DbContext.Provider value={props.client}>
|
|
17
|
+
{props.children}
|
|
18
|
+
</DbContext.Provider>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useDbClient(): DbClient {
|
|
23
|
+
const client = useContext(DbContext)
|
|
24
|
+
if (!client) {
|
|
25
|
+
throw new Error(`useDbClient must be used within a DbProvider.`)
|
|
26
|
+
}
|
|
27
|
+
return client
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useOptionalDbClient(): DbClient | undefined {
|
|
31
|
+
return useContext(DbContext)
|
|
32
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react'
|
|
4
|
+
import { useDbClient } from './DbProvider'
|
|
5
|
+
import type { DehydratedDbState } from '@tanstack/db'
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
|
|
8
|
+
export type HydrationBoundaryProps = {
|
|
9
|
+
state: DehydratedDbState
|
|
10
|
+
children?: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function HydrationBoundary({ state, children }: HydrationBoundaryProps) {
|
|
14
|
+
const client = useDbClient()
|
|
15
|
+
const hydrated = useRef<
|
|
16
|
+
{ client: typeof client; state: DehydratedDbState } | undefined
|
|
17
|
+
>(undefined)
|
|
18
|
+
|
|
19
|
+
if (hydrated.current?.client !== client || hydrated.current.state !== state) {
|
|
20
|
+
client.hydrate(state)
|
|
21
|
+
hydrated.current = { client, state }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return children
|
|
25
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// Re-export all public APIs
|
|
2
|
-
export
|
|
2
|
+
export { useLiveQuery } from './useLiveQuery'
|
|
3
|
+
export type {
|
|
4
|
+
LiveQueryKey,
|
|
5
|
+
UseLiveQueryConfig,
|
|
6
|
+
UseLiveQueryStatus,
|
|
7
|
+
} from './useLiveQuery'
|
|
8
|
+
export * from './DbProvider'
|
|
9
|
+
export * from './HydrationBoundary'
|
|
3
10
|
export * from './useLiveSuspenseQuery'
|
|
4
11
|
export * from './usePacedMutations'
|
|
5
12
|
export * from './useLiveInfiniteQuery'
|