@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.
Files changed (52) hide show
  1. package/README.md +12 -0
  2. package/dist/cjs/DbProvider.cjs +23 -0
  3. package/dist/cjs/DbProvider.cjs.map +1 -0
  4. package/dist/cjs/DbProvider.d.cts +9 -0
  5. package/dist/cjs/HydrationBoundary.cjs +16 -0
  6. package/dist/cjs/HydrationBoundary.cjs.map +1 -0
  7. package/dist/cjs/HydrationBoundary.d.cts +7 -0
  8. package/dist/cjs/index.cjs +6 -0
  9. package/dist/cjs/index.cjs.map +1 -1
  10. package/dist/cjs/index.d.cts +4 -1
  11. package/dist/cjs/live-query-internals.cjs +19 -0
  12. package/dist/cjs/live-query-internals.cjs.map +1 -0
  13. package/dist/cjs/live-query-internals.d.cts +9 -0
  14. package/dist/cjs/useLiveInfiniteQuery.cjs +76 -8
  15. package/dist/cjs/useLiveInfiniteQuery.cjs.map +1 -1
  16. package/dist/cjs/useLiveInfiniteQuery.d.cts +12 -58
  17. package/dist/cjs/useLiveQuery.cjs +263 -41
  18. package/dist/cjs/useLiveQuery.cjs.map +1 -1
  19. package/dist/cjs/useLiveQuery.d.cts +79 -33
  20. package/dist/cjs/useLiveSuspenseQuery.cjs +19 -5
  21. package/dist/cjs/useLiveSuspenseQuery.cjs.map +1 -1
  22. package/dist/cjs/useLiveSuspenseQuery.d.cts +22 -19
  23. package/dist/esm/DbProvider.d.ts +9 -0
  24. package/dist/esm/DbProvider.js +23 -0
  25. package/dist/esm/DbProvider.js.map +1 -0
  26. package/dist/esm/HydrationBoundary.d.ts +7 -0
  27. package/dist/esm/HydrationBoundary.js +16 -0
  28. package/dist/esm/HydrationBoundary.js.map +1 -0
  29. package/dist/esm/index.d.ts +4 -1
  30. package/dist/esm/index.js +6 -0
  31. package/dist/esm/index.js.map +1 -1
  32. package/dist/esm/live-query-internals.d.ts +9 -0
  33. package/dist/esm/live-query-internals.js +19 -0
  34. package/dist/esm/live-query-internals.js.map +1 -0
  35. package/dist/esm/useLiveInfiniteQuery.d.ts +12 -58
  36. package/dist/esm/useLiveInfiniteQuery.js +76 -8
  37. package/dist/esm/useLiveInfiniteQuery.js.map +1 -1
  38. package/dist/esm/useLiveQuery.d.ts +79 -33
  39. package/dist/esm/useLiveQuery.js +265 -43
  40. package/dist/esm/useLiveQuery.js.map +1 -1
  41. package/dist/esm/useLiveSuspenseQuery.d.ts +22 -19
  42. package/dist/esm/useLiveSuspenseQuery.js +19 -5
  43. package/dist/esm/useLiveSuspenseQuery.js.map +1 -1
  44. package/package.json +2 -2
  45. package/skills/react-db/SKILL.md +119 -90
  46. package/src/DbProvider.tsx +32 -0
  47. package/src/HydrationBoundary.tsx +25 -0
  48. package/src/index.ts +8 -1
  49. package/src/live-query-internals.ts +36 -0
  50. package/src/useLiveInfiniteQuery.ts +119 -65
  51. package/src/useLiveQuery.ts +441 -92
  52. 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 - Array of dependencies that trigger query re-execution when changed
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((q) =>
13
- * q.from({ todos: todosCollection })
14
- * .where(({ todos }) => eq(todos.completed, false))
15
- * .select(({ todos }) => ({ id: todos.id, text: todos.text }))
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
- * // With dependencies that trigger re-suspension
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
- * [minPriority] // Re-suspends when minPriority changes
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
- * ✅ **Or use useLiveQuery for conditional queries:**
88
+ * ✅ **For optional inputs, conditionally render a component with complete query inputs:**
88
89
  * ```ts
89
- * const { data, isEnabled } = useLiveQuery(
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
- function useLiveSuspenseQuery(configOrQueryOrCollection, deps = []) {
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 (collectionStatus === `ready`) {
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 (collectionStatus === `loading` || collectionStatus === `idle`) {
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 = result.collection.preload();
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":["import { useRef } from 'react'\nimport { useLiveQuery } 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 - 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((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 * // With dependencies that trigger re-suspension\n * const { data } = useLiveSuspenseQuery(\n * (q) => q.from({ todos: todosCollection })\n * .where(({ todos }) => gt(todos.priority, minPriority)),\n * [minPriority] // Re-suspends when minPriority changes\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 * (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 * ✅ **Or use useLiveQuery for conditional queries:**\n * ```ts\n * const { data, isEnabled } = useLiveQuery(\n * (q) => userId ? q.from({ users }) : undefined, // ✅ Supported!\n * [userId]\n * )\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: 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 3: 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 4: 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 = useLiveQuery(configOrQueryOrCollection, deps)\n\n // Reset promise and ready state when collection changes (deps changed)\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 // It’s not recommended to suspend a render based on a store value returned by useSyncExternalStore.\n // result.status is the snapshot from syncExternalStore. We read the fresh status from the collection reference instead.\n const collectionStatus = result.collection.status\n\n // Track when we reach ready state\n if (collectionStatus === `ready`) {\n hasBeenReadyRef.current = true\n promiseRef.current = null\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 (collectionStatus === `loading` || collectionStatus === `idle`) {\n // Create or reuse promise for current collection\n if (!promiseRef.current) {\n promiseRef.current = result.collection.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":";;AA0JO,SAAS,qBACd,2BACA,OAAuB,IACvB;AACA,QAAM,aAAa,OAA6B,IAAI;AACpD,QAAM,gBAAgB,OAAyC,IAAI;AACnE,QAAM,kBAAkB,OAAO,KAAK;AAGpC,QAAM,SAAS,aAAa,2BAA2B,IAAI;AAG3D,MAAI,cAAc,YAAY,OAAO,YAAY;AAC/C,eAAW,UAAU;AACrB,kBAAc,UAAU,OAAO;AAC/B,oBAAgB,UAAU;AAAA,EAC5B;AAIA,MAAI,CAAC,OAAO,WAAW;AAErB,UAAM,IAAI;AAAA,MACR;AAAA,IAAA;AAAA,EAMJ;AAIA,QAAM,mBAAmB,OAAO,WAAW;AAG3C,MAAI,qBAAqB,SAAS;AAChC,oBAAgB,UAAU;AAC1B,eAAW,UAAU;AAAA,EACvB;AAIA,MAAI,qBAAqB,WAAW,CAAC,gBAAgB,SAAS;AAC5D,eAAW,UAAU;AAGrB,UAAM,IAAI,MAAM,eAAe,OAAO,WAAW,EAAE,kBAAkB;AAAA,EACvE;AAEA,MAAI,qBAAqB,aAAa,qBAAqB,QAAQ;AAEjE,QAAI,CAAC,WAAW,SAAS;AACvB,iBAAW,UAAU,OAAO,WAAW,QAAA;AAAA,IACzC;AAIA,UAAM,WAAW;AAAA,EACnB;AAIA,SAAO;AAAA,IACL,OAAO,OAAO;AAAA,IACd,MAAM,OAAO;AAAA,IACb,YAAY,OAAO;AAAA,EAAA;AAEvB;"}
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.2.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.7.1"
44
+ "@tanstack/db": "0.8.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "react": ">=16.8.0"
@@ -1,9 +1,10 @@
1
1
  ---
2
2
  name: react-db
3
3
  description: >
4
- React bindings for TanStack DB. useLiveQuery hook with dependency arrays
5
- (8 overloads: query function, config object, pre-created collection,
6
- disabled state via returning undefined/null). useLiveSuspenseQuery for
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.0'
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 { useLiveQuery, eq, not } from '@tanstack/react-db'
34
+ import { eq, not, useLiveQuery } from '@tanstack/react-db'
34
35
 
35
36
  function TodoList() {
36
- const { data: todos, isLoading } = useLiveQuery((q) =>
37
- q
38
- .from({ todo: todoCollection })
39
- .where(({ todo }) => not(todo.completed))
40
- .orderBy(({ todo }) => todo.created_at, 'asc'),
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
- // Query function with dependency array
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
- [userId],
79
- )
80
+ })
80
81
 
81
- // Config object
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 — return undefined/null to disable
91
- const { data, status } = useLiveQuery(
92
- (q) => {
93
- if (!userId) return undefined
94
- return q
95
- .from({ todo: todoCollection })
96
- .where(({ todo }) => eq(todo.userId, userId))
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((q) =>
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
- // With deps — re-suspends when deps change
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
- [category],
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
- { pageSize: 20 },
142
- [category],
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((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
- )
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((q) =>
221
- q.from({ p: projectsCollection }).select(({ p }) => ({
222
- id: p.id,
223
- name: p.name,
224
- issues: toArray(
225
- q
226
- .from({ i: issuesCollection })
227
- .where(({ i }) => eq(i.projectId, p.id))
228
- .select(({ i }) => ({ id: i.id, title: i.title })),
229
- ),
230
- })),
231
- )
232
- // project.issues is string[] — no subcomponent needed
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 the row is confirmed by sync; `false` when it is still optimistic.
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
- ### Dependency arrays
262
+ ### Query identity
262
263
 
263
264
  ```tsx
264
- // Include ALL external reactive values
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
- [userId, filter],
273
- )
273
+ })
274
+
275
+ // Static query
276
+ const { data } = useLiveQuery({
277
+ query: (q) => q.from({ todo: todoCollection }),
278
+ })
279
+ ```
274
280
 
275
- // Empty array = static query, never re-runs
276
- const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }), [])
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
- // No array = re-runs on every render (usually wrong)
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((q) => q.from({ todo: todoCollection }))
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 Missing external values in dependency array
325
+ ### CRITICAL Using opaque query logic without queryKey
306
326
 
307
327
  Wrong:
308
328
 
309
329
  ```tsx
310
- const { data } = useLiveQuery((q) =>
311
- q.from({ todo: todoCollection }).where(({ todo }) => eq(todo.userId, userId)),
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
- (q) =>
320
- q
321
- .from({ todo: todoCollection })
322
- .where(({ todo }) => eq(todo.userId, userId)),
323
- [userId],
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
- When the query uses external state not in the deps array, the query won't re-run when that value changes, showing stale results.
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 `useLiveQuery` throws `InvalidSourceError: The value provided for alias "todo" is not a Collection`, it usually means two copies of `@tanstack/db` are installed. The collection was created by one copy, but `useLiveQuery` checks `instanceof` against the other.
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.0"
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 * from './useLiveQuery'
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'