Every non-trivial frontend eventually drowns in the same problem: data fetched from a dozen endpoints, scattered across React state, context, and a cache, all of it re-rendering far more than it should. You memoize, you lift state, you add a global store, and the app still feels sluggish the moment a list grows past a few thousand rows.
TanStack DB is the team's answer to that mess. It is a reactive client store that sits on top of TanStack Query, not as a replacement. Query keeps doing what it does best — fetching, caching, and revalidating from the server — and feeds that data into a local, queryable database with real relationships, live queries, and optimistic writes. The headline number: updating one row in a sorted collection of 100,000 items completes in roughly 0.7ms on an M1 Pro. That is fast enough that optimistic UI stops being a trick and starts being the default.
This guide walks through the core concepts and the API you will actually use.
The three primitives: collections, live queries, transactions
TanStack DB is built from three ideas that compose cleanly:
- Collections are typed sets of objects — your
todos,issues, orusers. Each item has a stable key. A collection is fed by a backing source: a TanStack Query endpoint, an ElectricSQL shape, local storage, or a fully custom sync function. - Live queries read from one or more collections using a relational query builder. They are reactive: when underlying data changes in a way that affects the result, only the affected part of the result is recomputed and re-rendered.
- Transactions wrap mutations. You change data optimistically in the local collection, the UI updates immediately, and a handler persists the change to your backend in the background — rolling back automatically if the write fails.
The reason this is more than a fancy cache is the engine underneath the live queries.
Differential dataflow: why queries are sub-millisecond
Most client state libraries re-run a selector or filter from scratch whenever anything changes. TanStack DB instead uses differential dataflow, implemented in TypeScript via a library called d2ts. Rather than recomputing a join, filter, or aggregate over the whole dataset, it recomputes only the delta — the rows that actually changed.
The practical effect: a complex query joining two collections and sorting 100,000 rows updates incrementally in under a millisecond when a single row changes. You can express the relational logic that product apps eventually need — cross-collection joins, aggregates, ordering — without paying the usual re-render tax. This is the difference that makes large local graphs feel instant.
Setting up a collection
A collection backed by TanStack Query needs a query key, a fetch function, a key extractor, and optional mutation handlers. Here is a todos collection wired to a REST API:
import { createCollection } from '@tanstack/react-db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'
const todoCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: async () => {
const response = await fetch('/api/todos')
return response.json()
},
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const { modified: newTodo } = transaction.mutations[0]
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
})
},
onUpdate: async ({ transaction }) => {
const { original, modified } = transaction.mutations[0]
await fetch(`/api/todos/${original.id}`, {
method: 'PUT',
body: JSON.stringify(modified),
})
},
onDelete: async ({ transaction }) => {
const { original } = transaction.mutations[0]
await fetch(`/api/todos/${original.id}`, { method: 'DELETE' })
},
})
)Notice that onInsert, onUpdate, and onDelete receive a transaction containing one or more mutations. The collection applies the change locally first, then your handler talks to the server. For a query-backed collection the handler must not resolve until the server state is synced back — TanStack DB auto-refetches after the handler completes, reconciling the optimistic state with the truth.
Reading data with useLiveQuery
You query collections through a builder rather than raw array methods. The builder mirrors SQL semantics — from, where, join, select, orderBy — but stays fully type-safe end to end. The simplest live query filters a single collection:
import { useLiveQuery, eq } from '@tanstack/react-db'
function ActiveTodos() {
const { data, isLoading } = useLiveQuery((q) =>
q.from({ todos: todoCollection })
.where(({ todos }) => eq(todos.completed, false))
.select(({ todos }) => ({ id: todos.id, text: todos.text }))
)
if (isLoading) return <p>Loading…</p>
return <ul>{data.map((t) => <li key={t.id}>{t.text}</li>)}</ul>
}The component re-renders only when a todo that matches the filter is added, removed, or changed — not on every cache write. Joins read as cleanly as a SQL statement, resolving relationships across collections without manual normalization:
const { data } = useLiveQuery((q) =>
q.from({ issues: issueCollection })
.join({ persons: personCollection }, ({ issues, persons }) =>
eq(issues.userId, persons.id)
)
.select(({ issues, persons }) => ({
id: issues.id,
title: issues.title,
userName: persons.name,
}))
)Live queries also re-execute when a reactive dependency changes. Pass the dependency and the builder reads its current value — for instance, a priority threshold controlled by a piece of component state:
const { data } = useLiveQuery(
(q) => q.from({ todos: todoCollection })
.where(({ todos }) => gt(todos.priority, minPriority))
)Optimistic mutations that feel instant
Because the local collection is the source of truth for the UI, writes land immediately. Calling insert, update, or delete on a collection returns a transaction you can await for persistence:
// Insert — UI updates instantly, server sync happens in the handler
const tx = todoCollection.insert({
id: crypto.randomUUID(),
text: 'Ship the feature',
completed: false,
})
await tx.isPersisted.promise // resolves once the backend confirmsThe flow is: apply locally, render, persist, reconcile. If the backend request throws, the transaction rolls the optimistic change back automatically, so a failed network call never leaves a phantom row on screen. This is why optimistic UI in TanStack DB requires no manual cache surgery — the rollback and the re-sync are part of the model.
Choosing a sync engine
The collection abstraction is deliberately backend-agnostic. You start with whatever you already have and swap the source later without rewriting your components or queries:
- queryCollectionOptions — any REST, GraphQL, or tRPC endpoint, via TanStack Query.
- electricCollectionOptions — real-time sync from Postgres through ElectricSQL shapes.
- localOnlyCollectionOptions — pure client-side state with no backend.
- localStorageCollectionOptions — persisted to the browser's local storage across reloads.
Because the query builder and the useLiveQuery API are identical across all of them, you can prototype against a plain REST endpoint and graduate to a streaming sync engine when you need genuine real-time collaboration — without touching the read side of your app.
Where it fits, and a note for MENA teams
TanStack DB is in beta at the time of writing, so treat it as production-promising rather than production-hardened, and pin your versions. It shines for data-dense, interactive product surfaces — dashboards, issue trackers, inboxes, admin panels — where you have many entities, frequent updates, and a need for joins on the client. For a mostly static marketing site, it is overkill; plain TanStack Query is enough.
For teams building for MENA markets, the local-first angle is worth weighing. A localOnly or localStorage collection keeps working state on the device, which can reduce round-trips on patchy connections and keep more user data client-side — a useful lever when you are reasoning about Tunisia's INPDP or Saudi Arabia's PDPL data-handling expectations. When you do reach for a sync engine like ElectricSQL, the question of where Postgres lives becomes a residency decision you control, not a default you inherit.
The takeaway
TanStack DB reframes client state as a small, fast, relational database rather than a pile of hooks and memoized selectors. Collections normalize your data, differential dataflow makes live queries effectively free, and transactions make optimistic writes the path of least resistance. If your app's frontend has outgrown ad-hoc caching, it is the most coherent answer the TanStack ecosystem has shipped — and it slots in beside the useQuery calls you already have.