@tanstack/react-db

React integration for @tanstack/db

react-db

frameworkreact
406 linesSource

React bindings for TanStack DB. useLiveQuery hook with dependency arrays (8 overloads: query function, config object, pre-created collection, disabled state via returning undefined/null). useLiveSuspenseQuery for React Suspense with Error Boundaries (data always defined). useLiveInfiniteQuery for cursor-based pagination (pageSize, fetchNextPage, hasNextPage, isFetchingNextPage). usePacedMutations for debounced React state updates. Return shape: data, state, collection, status, isLoading, isReady, isError. Import from @tanstack/react-db (re-exports all of @tanstack/db).

This skill builds on db-core. Read it first for collection setup, query builder, and mutation patterns.

TanStack DB — React

Setup

tsx
import { useLiveQuery, eq, not } from '@tanstack/react-db'

function TodoList() {
  const { data: todos, isLoading } = useLiveQuery((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

tsx
// Query function with dependency array
const {
  data,
  state,
  collection,
  status,
  isLoading,
  isReady,
  isError,
  isIdle,
  isCleanedUp,
} = useLiveQuery(
  (q) =>
    q
      .from({ todo: todoCollection })
      .where(({ todo }) => eq(todo.userId, userId)),
  [userId],
)

// Config object
const { data } = useLiveQuery({
  query: (q) => q.from({ todo: todoCollection }),
  gcTime: 60000,
})

// Pre-created collection (from route loader)
const { data } = useLiveQuery(preloadedCollection)

// Conditional query — return undefined/null to disable
const { data, status } = useLiveQuery(
  (q) => {
    if (!userId) return undefined
    return q
      .from({ todo: todoCollection })
      .where(({ todo }) => eq(todo.userId, userId))
  },
  [userId],
)
// When disabled: status='disabled', data=undefined

useLiveSuspenseQuery

tsx
// data is ALWAYS defined — never undefined
// Must wrap in <Suspense> and <ErrorBoundary>
function TodoList() {
  const { data: todos } = useLiveSuspenseQuery((q) =>
    q.from({ todo: todoCollection }),
  )

  return (
    <ul>
      {todos.map((t) => (
        <li key={t.id}>{t.text}</li>
      ))}
    </ul>
  )
}

// With deps — re-suspends when deps change
const { data } = useLiveSuspenseQuery(
  (q) =>
    q
      .from({ todo: todoCollection })
      .where(({ todo }) => eq(todo.category, category)),
  [category],
)

useLiveInfiniteQuery

tsx
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
  useLiveInfiniteQuery(
    (q) =>
      q
        .from({ posts: postsCollection })
        .orderBy(({ posts }) => posts.createdAt, 'desc'),
    { pageSize: 20 },
    [category],
  )

// data is the flat array of all loaded pages
// fetchNextPage() loads the next page
// hasNextPage is true when more data is available

usePacedMutations

tsx
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 onChange={(e) => 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:

tsx
function ProjectList() {
  const { data: projects } = useLiveQuery((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:

tsx
import { toArray, eq } from '@tanstack/react-db'

const { data: projects } = useLiveQuery((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 string[] — no subcomponent needed

See 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: true when the row is confirmed by sync; false when it is still optimistic.
  • $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.

tsx
const { data } = useLiveQuery(
  (q) =>
    q
      .from({ todo: todoCollection })
      .where(({ todo }) => eq(todo.$synced, false)),
  [],
)
// Shows only optimistic (unconfirmed) todos

React-Specific Patterns

Dependency arrays

tsx
// Include ALL external reactive values
const { data } = useLiveQuery(
  (q) =>
    q
      .from({ todo: todoCollection })
      .where(({ todo }) =>
        and(eq(todo.userId, userId), eq(todo.status, filter)),
      ),
  [userId, filter],
)

// Empty array = static query, never re-runs
const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }), [])

// No array = re-runs on every render (usually wrong)

Suspense + Error Boundary

tsx
<ErrorBoundary fallback={<div>Error</div>}>
  <Suspense fallback={<div>Loading...</div>}>
    <TodoList />
  </Suspense>
</ErrorBoundary>

Router loader preloading

tsx
// In route loader:
await todoCollection.preload()

// In component — data available immediately:
const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }))

See meta-framework/SKILL.md for full preloading patterns.

Common Mistakes

CRITICAL Missing external values in dependency array

Wrong:

tsx
const { data } = useLiveQuery((q) =>
  q.from({ todo: todoCollection }).where(({ todo }) => eq(todo.userId, userId)),
)

Correct:

tsx
const { data } = useLiveQuery(
  (q) =>
    q
      .from({ todo: todoCollection })
      .where(({ todo }) => eq(todo.userId, userId)),
  [userId],
)

When the query uses external state not in the deps array, the query won't re-run when that value changes, showing stale results.

Source: docs/framework/react/overview.md

HIGH useLiveSuspenseQuery without Error Boundary

Wrong:

tsx
<Suspense fallback={<div>Loading...</div>}>
  <TodoList /> {/* uses useLiveSuspenseQuery */}
</Suspense>

Correct:

tsx
<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 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.

In dev mode, TanStack DB also throws DuplicateDbInstanceError if two instances are detected.

Diagnose:

sh
pnpm ls @tanstack/db

If multiple versions appear, fix with one of:

pnpm overrides (in root package.json):

json
{
  "pnpm": {
    "overrides": {
      "@tanstack/db": "^0.6.0"
    }
  }
}

Vite resolve.alias (in vite.config.ts):

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 db-core/live-queries/SKILL.md § Common Mistakes for all constraints.

See also: db-core/live-queries/SKILL.md — for query builder API and all operators.

See also: db-core/mutations-optimistic/SKILL.md — for mutation patterns.

See also: meta-framework/SKILL.md — for preloading in route loaders.