TanStack DB React Adapter
Installation#
npm install @tanstack/react-dbReact 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:
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:
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:
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:
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:
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:
// 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:
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:
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:
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:
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.