TanStack DB in Practice: How a Client-Side DB Changes Optimistic Updates
Honestly, when I first heard "client-side embedded DB," my reaction was "just another buzzword." TanStack Query already handles server state management well — what's the point of adding yet another DB on top of it?
But if you've ever implemented complex optimistic updates in TanStack Query, you know the pain. Saving a previous data snapshot in onMutate, handling rollbacks in onError, invalidating caches in onSettled... for a single query it's manageable, but the moment you have two or three interrelated queries, the code spirals out of control. TanStack DB solves this problem wholesale with collection-based transactions. After reading this post, you won't need to write separate rollback logic for failed optimistic updates.
This post is aimed at anyone who has used TanStack Query at least once. If Query is still unfamiliar to you, it's worth building a solid foundation there first. All code examples are based on v0.6.
TL;DR — Automatic rollback for optimistic updates + sub-millisecond Live Queries + incremental adoption on top of TanStack Query. Cannot run standalone; must be used alongside TanStack Query.
Core Concepts
Before we start — TanStack DB does not work standalone without TanStack Query.
@tanstack/react-queryandQueryClientProviderare required. This is not a replacement for Query — it's a layer that sits on top of Query.
Query Cache vs. DB Collection — What's the Difference?
I also initially thought "isn't it just the same cache?" But looking at the internal structure, they're quite different.
| TanStack Query Cache | TanStack DB Collection | |
|---|---|---|
| Storage structure | Simple cache in Map<queryKey, data> form |
Normalized record index |
| Query method | Full replacement per queryKey | Field-level filtering, sorting, and joining |
| Updates | Invalidate entire query and refetch | Incremental update of only changed records |
| Relationships | Each query is independent, no relationship expression | Cross-collection relationship queries via Includes |
Query handles server communication (fetching, caching, revalidation), and DB receives that data and stores it in a local query engine in normalized form. This is exactly why both are needed.
Collections — Typed Local Tables
A Collection is the fundamental unit of TanStack DB. Think of it as a database table, but you can populate it from any source — REST, GraphQL, tRPC, Electric SQL, PowerSync, and more.
import { createCollection } from '@tanstack/db'
import { queryCollectionOptions } from '@tanstack/query-collection'
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(r => r.json()),
getId: (todo) => todo.id,
})
)Even if multiple components subscribe to the same collection, only a single network request is made. It uses the same queryKey-based caching principle from TanStack Query, with a normalized index layered on top.
Live Queries — Incremental Updates for Only What Changed
Live Queries are TanStack DB's core differentiator. Internally, it uses d2ts, a TypeScript implementation of Differential Dataflow — this library is bundled within the TanStack DB package, so no separate installation is needed.
I was also puzzled by "Differential Dataflow" at first, but in one sentence: when a query result changes, instead of recomputing everything from scratch, it finds and propagates only the changed parts. This is why a single-row update in a 100k-row collection can respond in ~0.7ms (on M1 Pro).
function ActiveTodos() {
const { data, isPending, error } = useLiveQuery((q) =>
q.from({ todos: todosCollection })
.where(({ todos }) => eq(todos.status, 'active'))
.orderBy(({ todos }) => todos.createdAt)
)
if (isPending) return <div>Loading...</div>
if (error) return <div>An error occurred: {error.message}</div>
return (
<ul>
{data.map(t => <li key={t.id}>{t.title}</li>)}
</ul>
)
}isPending and error are states you'll inevitably deal with in production code. Internalizing this pattern as a default will save you trouble down the road.
Optimistic Mutations — Rollback at the Transaction Level
const { mutate } = useOptimisticMutation({
mutationFn: (todo) => fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
body: JSON.stringify(todo),
}),
onMutate: (newTodo) => {
// Immediately reflect in the local collection before the server responds
todosCollection.update(newTodo)
},
// On failure, changes applied in onMutate are automatically rolled back
})The optimistic update code you used to write as an onMutate → onError → onSettled trio in TanStack Query is reduced to a single onMutate. Rollback is handled automatically at the collection transaction level.
Practical Application
Example 1: Sync Modes — Load Strategies Matched to Data Scale
Query-Driven Sync, introduced in v0.5, controls how much data is loaded into a collection and in what manner, via three modes.
| Mode | Behavior | Suitable Scenario |
|---|---|---|
| Eager (default) | Pre-loads the entire collection | Static reference data under 10k rows |
| On-Demand | Loads only the subset requested by the query | 50k+ rows, search/catalog UIs |
| Progressive | Loads query subset first, then syncs the full dataset in the background | Collaborative apps — instant first screen + sub-millisecond queries thereafter |
I initially loaded all 50k products with the default Eager mode and experienced nearly 8-second initial loads. That's what prompted me to migrate to On-Demand — once I understood the concept of query conditions being pushed down to the server, the selection criteria became clear.
// On-Demand mode: 50k+ product catalog
const productsCollection = createCollection(
queryCollectionOptions({
mode: 'on-demand',
queryFn: (params) =>
fetch(`/api/products?${params}`).then(r => r.json()),
getId: (p) => p.id,
})
)
// Live Query filter conditions are pushed down to the server loader
function ProductSearch({ keyword }: { keyword: string }) {
const { data, isPending } = useLiveQuery((q) =>
q.from({ products: productsCollection })
.where(({ products }) => like(products.name, `%${keyword}%`))
.limit(20)
)
if (isPending) return <ProductSkeleton />
return <ProductGrid items={data} />
}Example 2: Real-Time Postgres Sync with Electric SQL
When you want to stream backend Postgres changes to the client, you can use the Electric SQL adapter. Electric SQL itself requires a separate server setup in front of Postgres, but you don't need to change the Postgres schema itself. The client code is remarkably concise.
import { createCollection } from '@tanstack/db'
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
// Use a clear name to distinguish from queryCollectionOptions-based collections
const todosElectricCollection = createCollection(
electricCollectionOptions({
url: 'https://api.example.com/electric',
params: { table: 'todos' },
getId: (t) => t.id,
})
)
function TodoList() {
const { data } = useLiveQuery((q) =>
q.from({ todos: todosElectricCollection })
.orderBy(({ todos }) => todos.createdAt, 'desc')
)
return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>
}When a row changes in Postgres, it streams through Electric into the client collection, and any component connected via Live Query updates automatically. No polling, no WebSocket management code.
Example 3: Handling Hierarchical Data with v0.6 Includes
In production, the need for JOINs comes up constantly. v0.6's Includes lets you project normalized collections directly into your UI's hierarchical structure. Explicitly specifying type parameters ensures type inference works accurately for Includes queries.
interface User {
id: string
name: string
isActive: boolean
}
interface Todo {
id: string
userId: string
title: string
}
const usersCollection = createCollection<User>(
queryCollectionOptions({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
getId: (u) => u.id,
})
)
const todosCollection = createCollection<Todo>(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(r => r.json()),
getId: (t) => t.id,
})
)
function UserTodoList() {
const { data } = useLiveQuery((q) =>
q.from({ users: usersCollection })
.include('todos', (q) =>
q.from({ todos: todosCollection })
.where(({ todos }, { users }) => eq(todos.userId, users.id))
)
.where(({ users }) => eq(users.isActive, true))
)
// data is inferred as { ...User, todos: Todo[] }[]
return (
<div>
{data.map(user => (
<div key={user.id}>
<h3>{user.name}</h3>
<ul>{user.todos.map(t => <li key={t.id}>{t.title}</li>)}</ul>
</div>
))}
</div>
)
}
$synced·$originvirtual properties — I was puzzled by these at first too, but think of it as implementing the ✓, ✓✓ send-status indicators you see in WhatsApp messages.$syncedtracks whether a given record has been synchronized with the server, and$origintracks the data's source. This lets you implement an outbox UI natively without any separate state management.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Sub-millisecond queries | Thanks to Differential Dataflow, single-row updates in a 100k-row collection respond in ~0.7ms (M1 Pro) |
| Reliable optimistic UX | Automatic rollback at the transaction level greatly simplifies complex optimistic update patterns |
| Incremental adoption | No need to rewrite existing TanStack Query code all at once — migrate one collection at a time |
| Source-agnostic | REST, GraphQL, tRPC, Electric, PowerSync, and any other source can be wrapped as a collection |
| Offline-first (v0.6+) | SQLite backend preserves local data across app restarts, resuming sync on reconnect |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Still experimental | As of 2026, the API is still evolving rapidly | Pilot in peripheral domains, then expand to core domains |
| TanStack Query required | Cannot run standalone; requires understanding both libraries | Entry barrier drops significantly with a solid Query foundation |
| Learning curve | Differential Dataflow concepts, sync mode selection, etc. | Start with Eager mode + basic Live Queries, then expand as needed |
| Overkill for simple apps | Simple CRUD is fine with TanStack Query alone | Consider adoption when you have complex relational UIs or real-time/offline requirements |
| SQLite dependency (v0.6+) | Browser OPFS-based SQLite requires separate evaluation of support range and initial load cost | Check OPFS support status on caniuse.com; consider in-memory mode fallback if needed |
What is OPFS (Origin Private File System)? It was unfamiliar to me at first too — it's a sandboxed file system provided by the browser. It's used to run SQLite in a file-based manner in a web environment. As of 2026, it's supported in all major browsers, but if you need to support older environments, separate evaluation is required.
The Most Common Mistakes in Production
-
Attempting to use TanStack DB without TanStack Query: TanStack DB does not work independently. It must be installed alongside
@tanstack/react-query, andQueryClientProvideris still required. Starting without knowing this prerequisite will lead to hard-to-diagnose errors early on. -
Applying Eager mode to all data: Leaving the default Eager mode on tables with large row counts will fetch all data unnecessarily on initial load. For 50k+ rows, consider On-Demand mode; for collaborative apps, consider Progressive mode.
-
Making collection scope too broad: Trying to put everything into one giant collection actually makes management more complex. Separating collections by domain model and expressing relationships with Includes is a far more maintainable pattern.
Closing Thoughts
You can now implement safe mutations without writing separate rollback logic for when optimistic updates fail. Collection-based transactions carry that burden for you.
Three steps you can start with right now:
-
Install packages — Add the necessary packages with
pnpm add @tanstack/db @tanstack/query-collection @tanstack/react-query, then start by adding a single collection on top of your existing TanStack Query project. -
Replace one optimistic update — Find the mutation in your current project where the
onMutate·onError·onSettledtrio is most tangled, and replace it withuseOptimisticMutation. Once you feel how much simpler the rollback logic becomes, the rest follows naturally. -
Connect a Live Query to one collection — Pick a list in your UI that needs real-time updates and connect it with
useLiveQuery. HandleisPendinganderrorstates together, and you'll have a production-ready pattern immediately.
References
- TanStack DB Overview | TanStack Official Docs
- TanStack DB Quick Start | TanStack Official Docs
- Live Queries | TanStack Official Docs
- TanStack DB 0.6 — App-Ready with Persistence and Includes | TanStack Blog
- Local-First Sync with TanStack DB | Electric SQL
- How to use TanStack DB to build reactive, offline-ready React apps | LogRocket