Skip to content
Frameworks

TanStack DB React Adapter

Installation

shell
npm install @tanstack/react-db

React Hooks

See the React Functions Reference to see the full list of hooks available in the React Adapter.

For comprehensive documentation on writing queries (filtering, joins, aggregations, etc.), see the Live Queries Guide.

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(
  <DbProvider client={dbClient}>
    <App />
  </DbProvider>
)

useLiveQuery

The useLiveQuery hook creates a live query that automatically updates your component when data changes:

tsx
import { and, eq, gt, useDbClient, useLiveQuery } from '@tanstack/react-db'

function TodoList() {
  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 <div>Loading...</div>

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

Query Identity

React live 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. A changed query plan creates a new live-query Collection. Changing only a finite outer limit or offset moves the existing Collection's window.

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({
    query: (q) => q.from({ todos: todoCollection })
           .where(({ todos }) => gt(todos.priority, minPriority)),
  })

  return <div>{data.length} high-priority todos</div>
}

For queries without queryKey in one mounted hook and DbClient scope, a source Collection ID must keep referring to the same Collection object. If a render supplies a different source object with a previously used ID, the hook throws SourceCollectionIdReuseError instead of returning rows from the old source. To reuse an ID within that hook, unmount it, clean up its source and client scope, then mount the replacement. Separate hooks and independent client scopes can use the same ID.

A mounted binding keeps its Collection options until query identity changes. Independent query inputs to useLiveQuery and useLiveSuspenseQuery receive their own live-query Collections, even with equal query keys and options. Their source Collections can be shared. Passing the same pre-created Collection to both hooks explicitly shares that Collection. Changing only the outer finite limit or offset retains each hook's Collection, including under DbProvider. Cached DbClient results additionally reject conflicting getKey or schema references under the same query identity; use distinct query keys for those callers.

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

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 <div>{data.length} matching todos</div>
}

An explicit queryKey is the caller's identity contract. If its source object changes, change the key too, even when the source ID stays the same. Use a primitive scope token in the key; Collection objects cannot be hashed there.

Treat queryKey and its nested values as immutable. Fresh objects with equal contents keep the authored query, including when record properties appear in a different order. To change a nested value, replace that object and its parent objects, as with React state. In-place mutation has no supported invalidation behavior. This applies to both useLiveQuery and useLiveSuspenseQuery.

An unhashable query without queryKey throws UnhashableQueryIRError, so represent the values that opaque logic captures in the key. An empty queryKey counts as none, as it does for a DbClient preload. A dependency array does not replace queryKey for such a query. If deriving identity becomes expensive across renders, the hook warns once and suggests adding a queryKey as a performance escape hatch.

What Happens When Identity Changes

When an identity change requires a new query plan, the hook replaces its live-query Collection and renders the new result. While its initial result loads, useLiveSuspenseQuery shows the Suspense fallback and useLiveQuery reports loading. A finite outer limit or offset change retains the Collection and moves its window after commit.

Best Practices

Use structured expressions when possible:

tsx
// 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)
         )),
})

Add a query key for opaque runtime logic:

tsx
const { data } = useLiveQuery({
  queryKey: [todoCollection.id, 'by-user-fn', userId],
  query: (q) => q.from({ todos: todoCollection })
         .fn.where(({ todos }) => todos.userId === userId),
})

Omit query keys for static structured queries:

tsx
const { data } = useLiveQuery({
  query: (q) => q.from({ todos: todoCollection }),
})

For SSR setup, collection hydration, and migration details, see the SSR and Hydration guide.

useLiveInfiniteQuery

For paginated data with live updates, use useLiveInfiniteQuery:

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

fetchNextPage() returns a promise that resolves after the page request settles. A failed page load does not reject the promise or change status; it appears as a request error in the returned health tree.

This hook widens an ordered live query; it is not TanStack Query's useInfiniteQuery. getNextPageParam is not supported. initialPageParam only labels the returned pages; it does not set a server cursor or skip remote rows. For server pagination, use an on-demand Query Collection whose queryFn fulfills meta.loadSubsetOptions. See the server pagination guide.

React hook dependency arrays were removed in the first 1.0 RC. Use derived query identity or an explicit queryKey.

useLiveSuspenseQuery

For React Suspense integration, use useLiveSuspenseQuery:

tsx
function TodoList({ filter }: { filter: string }) {
  const { data } = useLiveSuspenseQuery({
    query: (q) => q.from({ todos: todoCollection })
           .where(({ todos }) => eq(todos.filter, filter)),
  })

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

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <TodoList filter="active" />
    </Suspense>
  )
}

When a changed query plan creates a new live-query Collection, useLiveSuspenseQuery shows your Suspense fallback until its initial result is ready. Finite outer-window moves retain the Collection and follow its window publication policy.