@tanstack/react-db 0.2.1 → 0.3.1

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 +107 -84
  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.1",
3
+ "version": "0.3.1",
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.2"
44
+ "@tanstack/db": "0.8.1"
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
@@ -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,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((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
- )
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
- ### Dependency arrays
262
+ ### Query identity
263
263
 
264
264
  ```tsx
265
- // Include ALL external reactive values
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
- [userId, filter],
274
- )
273
+ })
274
+
275
+ // Static query
276
+ const { data } = useLiveQuery({
277
+ query: (q) => q.from({ todo: todoCollection }),
278
+ })
279
+ ```
275
280
 
276
- // Empty array = static query, never re-runs
277
- 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:
278
284
 
279
- // 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
+ })
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((q) => q.from({ todo: todoCollection }))
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 Missing external values in dependency array
325
+ ### CRITICAL Using opaque query logic without queryKey
307
326
 
308
327
  Wrong:
309
328
 
310
329
  ```tsx
311
- const { data } = useLiveQuery((q) =>
312
- q.from({ todo: todoCollection }).where(({ todo }) => eq(todo.userId, userId)),
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
- (q) =>
321
- q
322
- .from({ todo: todoCollection })
323
- .where(({ todo }) => eq(todo.userId, userId)),
324
- [userId],
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
- 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.
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 * 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'
@@ -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
+ }