@tanstack/react-db 0.2.1 → 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 +107 -84
- 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
|
|
@@ -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,18 +217,19 @@ 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
|
-
)
|
|
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
|
+
})
|
|
232
233
|
// project.issues is Array<{ id: string; title: string }> — no subcomponent needed
|
|
233
234
|
```
|
|
234
235
|
|
|
@@ -247,38 +248,54 @@ Live query results include computed, read-only virtual properties on every row:
|
|
|
247
248
|
These props are added automatically and can be used in `where`, `select`, and `orderBy` clauses. Do not persist them back to storage.
|
|
248
249
|
|
|
249
250
|
```tsx
|
|
250
|
-
const { data } = useLiveQuery(
|
|
251
|
-
(q) =>
|
|
251
|
+
const { data } = useLiveQuery({
|
|
252
|
+
query: (q) =>
|
|
252
253
|
q
|
|
253
254
|
.from({ todo: todoCollection })
|
|
254
255
|
.where(({ todo }) => eq(todo.$synced, false)),
|
|
255
|
-
|
|
256
|
-
)
|
|
256
|
+
})
|
|
257
257
|
// Shows rows with pending local optimistic writes
|
|
258
258
|
```
|
|
259
259
|
|
|
260
260
|
## React-Specific Patterns
|
|
261
261
|
|
|
262
|
-
###
|
|
262
|
+
### Query identity
|
|
263
263
|
|
|
264
264
|
```tsx
|
|
265
|
-
//
|
|
266
|
-
const { data } = useLiveQuery(
|
|
267
|
-
(q) =>
|
|
265
|
+
// Structured captured values are included in the derived identity
|
|
266
|
+
const { data } = useLiveQuery({
|
|
267
|
+
query: (q) =>
|
|
268
268
|
q
|
|
269
269
|
.from({ todo: todoCollection })
|
|
270
270
|
.where(({ todo }) =>
|
|
271
271
|
and(eq(todo.userId, userId), eq(todo.status, filter)),
|
|
272
272
|
),
|
|
273
|
-
|
|
274
|
-
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// Static query
|
|
276
|
+
const { data } = useLiveQuery({
|
|
277
|
+
query: (q) => q.from({ todo: todoCollection }),
|
|
278
|
+
})
|
|
279
|
+
```
|
|
275
280
|
|
|
276
|
-
|
|
277
|
-
|
|
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:
|
|
278
284
|
|
|
279
|
-
|
|
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
|
+
})
|
|
280
293
|
```
|
|
281
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
|
+
|
|
282
299
|
### Suspense + Error Boundary
|
|
283
300
|
|
|
284
301
|
```tsx
|
|
@@ -296,36 +313,42 @@ const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }), [])
|
|
|
296
313
|
await todoCollection.preload()
|
|
297
314
|
|
|
298
315
|
// In component — data available immediately:
|
|
299
|
-
const { data } = useLiveQuery(
|
|
316
|
+
const { data } = useLiveQuery({
|
|
317
|
+
query: (q) => q.from({ todo: todoCollection }),
|
|
318
|
+
})
|
|
300
319
|
```
|
|
301
320
|
|
|
302
321
|
See meta-framework/SKILL.md for full preloading patterns.
|
|
303
322
|
|
|
304
323
|
## Common Mistakes
|
|
305
324
|
|
|
306
|
-
### CRITICAL
|
|
325
|
+
### CRITICAL Using opaque query logic without queryKey
|
|
307
326
|
|
|
308
327
|
Wrong:
|
|
309
328
|
|
|
310
329
|
```tsx
|
|
311
|
-
const { data } = useLiveQuery(
|
|
312
|
-
|
|
313
|
-
)
|
|
330
|
+
const { data } = useLiveQuery({
|
|
331
|
+
query: (q) =>
|
|
332
|
+
q.from({ todo: todoCollection }).fn.where(({ todo }) => {
|
|
333
|
+
return fuzzyMatch(todo.title, search)
|
|
334
|
+
}),
|
|
335
|
+
})
|
|
314
336
|
```
|
|
315
337
|
|
|
316
338
|
Correct:
|
|
317
339
|
|
|
318
340
|
```tsx
|
|
319
|
-
const { data } = useLiveQuery(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
)
|
|
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
|
+
})
|
|
326
348
|
```
|
|
327
349
|
|
|
328
|
-
|
|
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.
|
|
329
352
|
|
|
330
353
|
Source: docs/framework/react/overview.md
|
|
331
354
|
|
|
@@ -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'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DbClient,
|
|
3
|
+
LiveQueryObserver,
|
|
4
|
+
UnhashableQueryIRError,
|
|
5
|
+
} from '@tanstack/db'
|
|
6
|
+
|
|
7
|
+
const liveQueryResultInfo = Symbol(`liveQueryResultInfo`)
|
|
8
|
+
|
|
9
|
+
export type LiveQueryResultInfo = {
|
|
10
|
+
client: DbClient | undefined
|
|
11
|
+
queryHash: string | undefined
|
|
12
|
+
identityError: UnhashableQueryIRError | undefined
|
|
13
|
+
observer: LiveQueryObserver<object, string | number>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ResultWithInfo = {
|
|
17
|
+
[liveQueryResultInfo]?: LiveQueryResultInfo
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setLiveQueryResultInfo(
|
|
21
|
+
result: object,
|
|
22
|
+
info: LiveQueryResultInfo,
|
|
23
|
+
): void {
|
|
24
|
+
Object.defineProperty(result, liveQueryResultInfo, {
|
|
25
|
+
configurable: true,
|
|
26
|
+
value: info,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getLiveQueryResultInfo(result: object): LiveQueryResultInfo {
|
|
31
|
+
const info = (result as ResultWithInfo)[liveQueryResultInfo]
|
|
32
|
+
if (!info) {
|
|
33
|
+
throw new Error(`Missing internal live query result information.`)
|
|
34
|
+
}
|
|
35
|
+
return info
|
|
36
|
+
}
|