This skill builds on db-core. Read it first for collection setup, query builder, and mutation patterns.
TanStack DB — React
Setup
import { eq, not, useLiveQuery } from '@tanstack/react-db'
function TodoList() {
const { data: todos, isLoading } = useLiveQuery({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => not(todo.completed))
.orderBy(({ todo }) => todo.created_at, 'asc'),
})
if (isLoading) return <div>Loading...</div>
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
)
}
@tanstack/react-db re-exports everything from @tanstack/db. In React projects, import everything from @tanstack/react-db.
Hooks
useLiveQuery
// Preferred config object with derived query identity
const {
data,
state,
collection,
status,
isLoading,
isReady,
isError,
isIdle,
isCleanedUp,
} = useLiveQuery({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.userId, userId)),
})
// Static query
const { data } = useLiveQuery({
query: (q) => q.from({ todo: todoCollection }),
gcTime: 60000,
})
// Pre-created collection (from route loader)
const { data } = useLiveQuery(preloadedCollection)
// Conditional query — derived identity handles enabled/disabled transitions
const { data, status } = useLiveQuery({
query: (q) => {
if (!userId) return undefined
return q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.userId, userId))
},
})
// When disabled: status='disabled', data=undefined
useLiveSuspenseQuery
// data is ALWAYS defined — never undefined
// Must wrap in <Suspense> and <ErrorBoundary>
function TodoList() {
const { data: todos } = useLiveSuspenseQuery({
query: (q) => q.from({ todo: todoCollection }),
})
return (
<ul>
{todos.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
)
}
// Structured captured values are part of the derived identity and re-suspend when changed
const { data } = useLiveSuspenseQuery({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.category, category)),
})
useLiveInfiniteQuery
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useLiveInfiniteQuery(
(q) =>
q
.from({ posts: postsCollection })
.where(({ posts }) => eq(posts.category, category))
.orderBy(({ posts }) => posts.createdAt, 'desc'),
{
pageSize: 20,
},
)
// data is the flat array of all loaded pages
// fetchNextPage() loads the next page
// hasNextPage is true when more data is available
usePacedMutations
import { usePacedMutations, debounceStrategy } from "@tanstack/react-db"
const mutate = usePacedMutations({
onMutate: (value: string) => {
noteCollection.update(noteId, (draft) => {
draft.content = value
})
},
mutationFn: async ({ transaction }) => {
await api.notes.update(noteId, transaction.mutations[0].changes)
},
strategy: debounceStrategy({ wait: 500 }),
})
// In handler:
<textarea => mutate(e.target.value)} />
Includes (Hierarchical Data)
When a query uses includes (subqueries in select), each child field is a live Collection by default. Subscribe to it with useLiveQuery in a subcomponent:
function ProjectList() {
const { data: projects } = useLiveQuery({
query: (q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({ id: i.id, title: i.title })),
})),
})
return (
<ul>
{projects.map((project) => (
<li key={project.id}>
{project.name}
<IssueList issuesCollection={project.issues} />
</li>
))}
</ul>
)
}
// Child component subscribes to the child Collection
function IssueList({ issuesCollection }) {
const { data: issues } = useLiveQuery(issuesCollection)
return (
<ul>
{issues.map((issue) => (
<li key={issue.id}>{issue.title}</li>
))}
</ul>
)
}
Only the affected IssueList re-renders when an issue changes — the parent does not.
With toArray(), child results are plain arrays and the parent re-renders on child changes:
import { toArray, eq } from '@tanstack/react-db'
const { data: projects } = useLiveQuery({
query: (q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: toArray(
q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({ id: i.id, title: i.title })),
),
})),
})
// project.issues is Array<{ id: string; title: string }> — no subcomponent needed
See ../tanstack-db-core-live-queries/SKILL.md for full includes rules (correlation conditions, nested includes, aggregates).
Virtual Properties
Live query results include computed, read-only virtual properties on every row:
$synced:truewhen no pending local optimistic write affects the row;falsewhile one does. It does not prove backend confirmation.$origin:"local"if the last confirmed change came from this client, otherwise"remote".$key: the row key for the result.$collectionId: the source collection ID.
These props are added automatically and can be used in where, select, and orderBy clauses. Do not persist them back to storage.
const { data } = useLiveQuery({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.$synced, false)),
})
// Shows rows with pending local optimistic writes
React-Specific Patterns
Query identity
// Structured captured values are included in the derived identity
const { data } = useLiveQuery({
query: (q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) =>
and(eq(todo.userId, userId), eq(todo.status, filter)),
),
})
// Static query
const { data } = useLiveQuery({
query: (q) => q.from({ todo: todoCollection }),
})
Use queryKey only when DB cannot derive identity from structured IR, such as
.fn.where, .fn.select, .fn.having, or as a deliberate performance escape
hatch on a hot render path:
const { data } = useLiveQuery({
queryKey: [todoCollection.id, 'search', search],
query: (q) =>
q.from({ todo: todoCollection }).fn.where(({ todo }) => {
return fuzzyMatch(todo.title, search)
}),
})
Before 1.0, opaque IR warns and keeps legacy mount-stable identity. Slow or
repeated derived identity work also warns once. Both point to the same
queryKey escape hatch; unhashable IR without a key will throw in 1.0.
Suspense + Error Boundary
<ErrorBoundary fallback={<div>Error</div>}>
<Suspense fallback={<div>Loading...</div>}>
<TodoList />
</Suspense>
</ErrorBoundary>
Router loader preloading
// In route loader:
await todoCollection.preload()
// In component — data available immediately:
const { data } = useLiveQuery({
query: (q) => q.from({ todo: todoCollection }),
})
See ../tanstack-db-meta-framework/SKILL.md for full preloading patterns.
Common Mistakes
CRITICAL Using opaque query logic without queryKey
Wrong:
const { data } = useLiveQuery({
query: (q) =>
q.from({ todo: todoCollection }).fn.where(({ todo }) => {
return fuzzyMatch(todo.title, search)
}),
})
Correct:
const { data } = useLiveQuery({
queryKey: [todoCollection.id, 'search', search],
query: (q) =>
q.from({ todo: todoCollection }).fn.where(({ todo }) => {
return fuzzyMatch(todo.title, search)
}),
})
Structured expressions are hashable by default. Functional query variants are opaque runtime code, so they need an explicit key to say when identity changes.
Source: docs/framework/react/overview.md
HIGH useLiveSuspenseQuery without Error Boundary
Wrong:
<Suspense fallback={<div>Loading...</div>}>
<TodoList /> {/* uses useLiveSuspenseQuery */}
</Suspense>
Correct:
<ErrorBoundary fallback={<div>Error</div>}>
<Suspense fallback={<div>Loading...</div>}>
<TodoList />
</Suspense>
</ErrorBoundary>
useLiveSuspenseQuery throws errors during rendering. Without an Error Boundary, the entire app crashes.
Source: docs/guides/live-queries.md
HIGH "Not a Collection" error from duplicate @tanstack/db
If a query-builder alias throws
InvalidSourceError: The value provided for alias "todo" is not a Collection,
it can mean two copies of @tanstack/db are installed. Direct
useLiveQuery(preCreatedCollection) detection is structural and works across
package copies or realms, but q.from({ todo: collection }) still validates
the source with the core collection class.
In dev mode, TanStack DB also throws DuplicateDbInstanceError if two instances are detected.
Diagnose:
pnpm ls @tanstack/db
If multiple versions appear, fix with one of:
pnpm overrides (in root package.json):
{
"pnpm": {
"overrides": {
"@tanstack/db": "^0.6.17"
}
}
}
Vite resolve.alias (in vite.config.ts):
import path from 'path'
export default defineConfig({
resolve: {
alias: {
'@tanstack/db': path.resolve('./node_modules/@tanstack/db'),
},
},
})
The root cause is typically a dependency that bundles its own copy instead of declaring @tanstack/db as a peerDependency.
HIGH Tension: Query expressiveness vs. IVM constraints
The query builder looks like SQL but has constraints that SQL doesn't — equality joins only, orderBy required for limit/offset, no distinct without select. Agents write SQL-style queries that violate these constraints. See ../tanstack-db-core-live-queries/SKILL.md § Common Mistakes for all constraints.
See also: ../tanstack-db-core-live-queries/SKILL.md — for query builder API and all operators.
See also: ../tanstack-db-core-mutations-optimistic/SKILL.md — for mutation patterns.
See also: ../tanstack-db-meta-framework/SKILL.md — for preloading in route loaders.