diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3678cd19f2..7dff92fade 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -45,6 +45,7 @@ jobs: run: | pnpm --filter @tanstack/db-ivm build pnpm --filter @tanstack/db build + pnpm --filter @tanstack/react-db build pnpm --filter @tanstack/electric-db-collection build pnpm --filter @tanstack/offline-transactions build pnpm --filter @tanstack/query-db-collection build @@ -68,6 +69,16 @@ jobs: env: ELECTRIC_URL: http://localhost:3000 + - name: Install Playwright browsers + run: | + cd examples/react/start-ssr-e2e + pnpm exec playwright install --with-deps chromium + + - name: Run React Start SSR E2E tests + run: | + cd examples/react/start-ssr-e2e + pnpm test:e2e + - name: Run Node SQLite persisted collection E2E tests run: | cd packages/node-db-sqlite-persistence diff --git a/docs/collections/local-only-collection.md b/docs/collections/local-only-collection.md index 17bf51ef4b..2f016baac5 100644 --- a/docs/collections/local-only-collection.md +++ b/docs/collections/local-only-collection.md @@ -192,10 +192,12 @@ export const modalStateCollection = createCollection( // Use in component function UserProfileModal() { - const { data: modals } = useLiveQuery((q) => - q.from({ modal: modalStateCollection }) - .where(({ modal }) => eq(modal.id, 'user-profile')) - ) + const { data: modals } = useLiveQuery({ + query: (q) => + q + .from({ modal: modalStateCollection }) + .where(({ modal }) => eq(modal.id, 'user-profile')), + }) const modalState = modals[0] @@ -248,10 +250,12 @@ export const formDraftsCollection = createCollection( // Use in component function CreatePostForm() { - const { data: drafts } = useLiveQuery((q) => - q.from({ draft: formDraftsCollection }) - .where(({ draft }) => eq(draft.id, 'new-post')) - ) + const { data: drafts } = useLiveQuery({ + query: (q) => + q + .from({ draft: formDraftsCollection }) + .where(({ draft }) => eq(draft.id, 'new-post')), + }) const currentDraft = drafts[0] diff --git a/docs/collections/local-storage-collection.md b/docs/collections/local-storage-collection.md index 171e5cb9b4..59d3a981c0 100644 --- a/docs/collections/local-storage-collection.md +++ b/docs/collections/local-storage-collection.md @@ -263,10 +263,12 @@ export const userPreferencesCollection = createCollection( // Use in component function SettingsPanel() { - const { data: prefs } = useLiveQuery((q) => - q.from({ pref: userPreferencesCollection }) - .where(({ pref }) => eq(pref.id, 'current-user')) - ) + const { data: prefs } = useLiveQuery({ + query: (q) => + q + .from({ pref: userPreferencesCollection }) + .where(({ pref }) => eq(pref.id, 'current-user')), + }) const currentPrefs = prefs[0] diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 73f3511edc..b03e6dbf89 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -25,13 +25,15 @@ npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db ```typescript import { QueryClient } from "@tanstack/query-core" -import { createCollection } from "@tanstack/db" +import { DbClient, collectionOptions } from "@tanstack/db" import { queryCollectionOptions } from "@tanstack/query-db-collection" const queryClient = new QueryClient() +const db = new DbClient() -const todosCollection = createCollection( +const todosCollection = collectionOptions( queryCollectionOptions({ + id: "todos", queryKey: ["todos"], queryFn: async () => { const response = await fetch("/api/todos") @@ -41,6 +43,8 @@ const todosCollection = createCollection( getKey: (item) => item.id, }) ) + +const todos = db.collection(todosCollection) ``` ## Configuration Options @@ -70,11 +74,12 @@ If your app already uses TanStack Query's `queryOptions` helper (e.g. from `@tan ```typescript import { QueryClient } from "@tanstack/query-core" -import { createCollection } from "@tanstack/db" +import { DbClient, collectionOptions } from "@tanstack/db" import { queryCollectionOptions } from "@tanstack/query-db-collection" import { queryOptions } from "@tanstack/react-query" const queryClient = new QueryClient() +const db = new DbClient() const listOptions = queryOptions({ queryKey: ["todos"], @@ -84,14 +89,17 @@ const listOptions = queryOptions({ }, }) -const todosCollection = createCollection( +const todosCollection = collectionOptions( queryCollectionOptions({ + id: "todos", ...listOptions, queryFn: (context) => listOptions.queryFn!(context), queryClient, getKey: (item) => item.id, }), ) + +const todos = db.collection(todosCollection) ``` If `queryFn` is missing at runtime, `queryCollectionOptions` throws `QueryFnRequiredError`. diff --git a/docs/collections/trailbase-collection.md b/docs/collections/trailbase-collection.md index 938e714a52..741cd8d80d 100644 --- a/docs/collections/trailbase-collection.md +++ b/docs/collections/trailbase-collection.md @@ -194,11 +194,13 @@ export const todosCollection = createCollection( // Use in component function TodoList() { - const { data: todos } = useLiveQuery((q) => - q.from({ todo: todosCollection }) - .where(({ todo }) => not(todo.completed)) - .orderBy(({ todo }) => todo.created_at, 'desc') - ) + const { data: todos } = useLiveQuery({ + query: (q) => + q + .from({ todo: todosCollection }) + .where(({ todo }) => not(todo.completed)) + .orderBy(({ todo }) => todo.created_at, 'desc'), + }) const addTodo = (text: string) => { todosCollection.insert({ diff --git a/docs/framework/react/overview.md b/docs/framework/react/overview.md index 1c10d644c6..0b72d90d06 100644 --- a/docs/framework/react/overview.md +++ b/docs/framework/react/overview.md @@ -17,19 +17,34 @@ For comprehensive documentation on writing queries (filtering, joins, aggregatio ## Basic Usage +Create a `DbClient` and provide it to your React tree: + +```tsx +import { DbClient, DbProvider } from '@tanstack/react-db' + +const dbClient = new DbClient() + +root.render( + + + +) +``` + ### useLiveQuery The `useLiveQuery` hook creates a live query that automatically updates your component when data changes: ```tsx -import { useLiveQuery, eq } from '@tanstack/react-db' +import { and, eq, gt, useDbClient, useLiveQuery } from '@tanstack/react-db' function TodoList() { - const { data, isLoading } = useLiveQuery((q) => - q.from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.completed, false)) - .select(({ todos }) => ({ id: todos.id, text: todos.text })) - ) + const { data, isLoading } = useLiveQuery({ + query: (q) => + q.from({ todos: todoCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })), + }) if (isLoading) return
Loading...
@@ -41,29 +56,56 @@ function TodoList() { } ``` -### Dependency Arrays +### Query Identity -All query hooks (`useLiveQuery`, `useLiveInfiniteQuery`, `useLiveSuspenseQuery`) accept an optional dependency array as their last parameter. This array works similarly to React's `useEffect` dependencies - when any value in the array changes, the query is recreated and re-executed. +React query hooks derive the live query identity from structured query IR by default. The hook runs the query builder, normalizes the resulting IR, and uses that as the identity. When the derived identity changes, the old live query collection is cleaned up and a new one is created. -#### When to Use Dependency Arrays - -Use dependency arrays when your query depends on external reactive values (props, state, or other hooks): +That means normal structured queries do not need a separate `queryKey`. Collection descriptors provide stable collection IDs, and captured values inside structured expressions become part of the derived identity: ```tsx function FilteredTodos({ minPriority }: { minPriority: number }) { - const { data } = useLiveQuery( - (q) => q.from({ todos: todosCollection }) + const { data } = useLiveQuery({ + query: (q) => q.from({ todos: todoCollection }) .where(({ todos }) => gt(todos.priority, minPriority)), - [minPriority] // Re-run when minPriority changes - ) + }) return
{data.length} high-priority todos
} ``` -#### What Happens When Dependencies Change +#### Collection Hooks + +`useLiveQuery` resolves collection descriptors from `DbProvider` automatically. Create small collection hooks when components need imperative collection methods like `insert`, `update`, `delete`, or `preload`: + +```tsx +function useTodoCollection() { + return useDbClient().collection(todoCollection) +} +``` + +#### When to Use Query Keys -When a dependency value changes: +Use `queryKey` only when DB cannot derive identity from structured IR, or when you intentionally want to avoid deriving identity on a hot render path. The common case is a functional query variant such as `.fn.where`, `.fn.select`, or `.fn.having`: + +```tsx +function SearchTodos({ search }: { search: string }) { + const { data } = useLiveQuery({ + queryKey: [todoCollection.id, 'search', search], + query: (q) => q.from({ todos: todoCollection }) + .fn.where(({ todos }) => + todos.text.toLowerCase().includes(search.toLowerCase()) + ), + }) + + return
{data.length} matching todos
+} +``` + +In development, `useLiveQuery` enforces this boundary. If the structured IR contains opaque values that cannot be hashed, it throws and points at the path that needs an explicit `queryKey`. If deriving identity becomes expensive across renders, it warns once and suggests adding a `queryKey` as a performance escape hatch. + +#### What Happens When Identity Changes + +When the derived identity or explicit query key changes: 1. The previous live query collection is cleaned up 2. A new query is created with the updated values 3. The component re-renders with the new data @@ -71,46 +113,39 @@ When a dependency value changes: #### Best Practices -**Include all external values used in the query:** +**Use structured expressions when possible:** ```tsx -// Good - all external values in deps -const { data } = useLiveQuery( - (q) => q.from({ todos: todosCollection }) +// Good - DB can derive identity from this structured IR +const { data } = useLiveQuery({ + query: (q) => q.from({ todos: todoCollection }) .where(({ todos }) => and( eq(todos.userId, userId), eq(todos.status, status) )), - [userId, status] -) - -// Bad - missing dependencies -const { data } = useLiveQuery( - (q) => q.from({ todos: todosCollection }) - .where(({ todos }) => eq(todos.userId, userId)), - [] // Missing userId! -) +}) ``` -**Empty array for static queries:** +**Add a query key for opaque runtime logic:** ```tsx -// No external dependencies - query never changes -const { data } = useLiveQuery( - (q) => q.from({ todos: todosCollection }), - [] -) +const { data } = useLiveQuery({ + queryKey: [todoCollection.id, 'by-user-fn', userId], + query: (q) => q.from({ todos: todoCollection }) + .fn.where(({ todos }) => todos.userId === userId), +}) ``` -**Omit the array for queries with no external dependencies:** +**Omit query keys for static structured queries:** ```tsx -// Same as above - no deps needed -const { data } = useLiveQuery( - (q) => q.from({ todos: todosCollection }) -) +const { data } = useLiveQuery({ + query: (q) => q.from({ todos: todoCollection }), +}) ``` +Dependency arrays are still accepted for backwards compatibility, but they warn in development and will be removed in 1.0. + ### useLiveInfiniteQuery For paginated data with live updates, use `useLiveInfiniteQuery`: @@ -125,24 +160,20 @@ const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( pageSize: 20, getNextPageParam: (lastPage, allPages) => lastPage.length === 20 ? allPages.length : undefined - }, - [category] // Re-run when category changes + } ) ``` -**Note:** The dependency array is only available when using the query function variant, not when passing a pre-created collection. - ### useLiveSuspenseQuery For React Suspense integration, use `useLiveSuspenseQuery`: ```tsx function TodoList({ filter }: { filter: string }) { - const { data } = useLiveSuspenseQuery( - (q) => q.from({ todos: todosCollection }) + const { data } = useLiveSuspenseQuery({ + query: (q) => q.from({ todos: todoCollection }) .where(({ todos }) => eq(todos.filter, filter)), - [filter] // Re-suspends when filter changes - ) + }) return (