TanStack DB Skill
Expert guidance for TanStack DB - the reactive client store for building local-first apps with sub-millisecond queries, optimistic mutations, and real-time sync.
Note: TanStack DB is currently in BETA.
Quick Reference
Core Concepts
Concept Purpose
Collection Typed set of objects (like a DB table)
Live Query Reactive query that updates incrementally
Optimistic Mutation Instant local write, synced in background
Sync Engine Real-time data sync (Electric, RxDB, PowerSync)
Project Structure
src/ ├── collections/ │ ├── todos.ts # Todo collection definition │ ├── users.ts # User collection │ └── index.ts # Export all collections ├── queries/ │ └── hooks.ts # Custom live query hooks └── lib/ └── db.ts # DB setup & QueryClient
Installation
Core + React
npm install @tanstack/react-db @tanstack/db
With TanStack Query (REST APIs)
npm install @tanstack/query-db-collection @tanstack/react-query
With ElectricSQL (Postgres sync)
npm install @tanstack/electric-db-collection
With RxDB (offline-first)
npm install @tanstack/rxdb-db-collection rxdb
Collections
Query Collection (REST API)
// src/collections/todos.ts import { createCollection } from "@tanstack/react-db" import { queryCollectionOptions } from "@tanstack/query-db-collection" import { queryClient } from "@/lib/db"
export interface Todo { id: string text: string completed: boolean createdAt: string }
export const todosCollection = createCollection( queryCollectionOptions({ queryKey: ["todos"], queryFn: async () => { const res = await fetch("/api/todos") return res.json() as Promise<Todo[]> }, queryClient, getKey: (item) => item.id,
// Persistence handlers
onInsert: async ({ transaction }) => {
const items = transaction.mutations.map((m) => m.modified)
await fetch("/api/todos", {
method: "POST",
body: JSON.stringify(items),
})
},
onUpdate: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((m) =>
fetch(`/api/todos/${m.key}`, {
method: "PATCH",
body: JSON.stringify(m.changes),
})
)
)
},
onDelete: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((m) =>
fetch(`/api/todos/${m.key}`, { method: "DELETE" })
)
)
},
}) )
Electric Collection (Postgres Sync)
// src/collections/todos.ts import { createCollection } from "@tanstack/react-db" import { electricCollectionOptions } from "@tanstack/electric-db-collection"
export const todosCollection = createCollection( electricCollectionOptions({ id: "todos", shapeOptions: { url: "/api/electric/todos", // Proxy to Electric }, getKey: (item) => item.id,
// Use transaction ID for sync confirmation
onInsert: async ({ transaction }) => {
const item = transaction.mutations[0].modified
const res = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify(item),
})
const { txid } = await res.json()
return { txid } // Electric waits for this txid
},
onUpdate: async ({ transaction }) => {
const { key, changes } = transaction.mutations[0]
const res = await fetch(`/api/todos/${key}`, {
method: "PATCH",
body: JSON.stringify(changes),
})
const { txid } = await res.json()
return { txid }
},
onDelete: async ({ transaction }) => {
const { key } = transaction.mutations[0]
const res = await fetch(`/api/todos/${key}`, { method: "DELETE" })
const { txid } = await res.json()
return { txid }
},
}) )
Sync Modes
// Eager (default): Load all upfront - best for <10k rows electricCollectionOptions({ sync: { mode: "eager" }, // ... })
// On-demand: Load only what queries request - best for >50k rows electricCollectionOptions({ sync: { mode: "on-demand" }, // ... })
// Progressive: Instant query results + background full sync electricCollectionOptions({ sync: { mode: "progressive" }, // ... })
Live Queries
Basic Query
import { useLiveQuery } from "@tanstack/react-db" import { todosCollection } from "@/collections/todos"
function TodoList() { const { data: todos, isLoading } = useLiveQuery((q) => q.from({ todo: todosCollection }) )
if (isLoading) return <div>Loading...</div>
return ( <ul> {todos?.map((todo) => ( <li key={todo.id}>{todo.text}</li> ))} </ul> ) }
Filtering with Where
import { eq, gt, and, or, like, inArray } from "@tanstack/react-db"
// Simple equality useLiveQuery((q) => q.from({ todo: todosCollection }) .where(({ todo }) => eq(todo.completed, false)) )
// Multiple conditions useLiveQuery((q) => q.from({ todo: todosCollection }) .where(({ todo }) => and( eq(todo.completed, false), gt(todo.priority, 5) ) ) )
// OR conditions useLiveQuery((q) => q.from({ todo: todosCollection }) .where(({ todo }) => or( eq(todo.status, "urgent"), eq(todo.status, "high") ) ) )
// String matching useLiveQuery((q) => q.from({ todo: todosCollection }) .where(({ todo }) => like(todo.text, "%meeting%")) )
// In array useLiveQuery((q) => q.from({ todo: todosCollection }) .where(({ todo }) => inArray(todo.id, ["1", "2", "3"])) )
Comparison Operators
Operator Description
eq(a, b)
Equal
gt(a, b)
Greater than
gte(a, b)
Greater than or equal
lt(a, b)
Less than
lte(a, b)
Less than or equal
like(a, pattern)
Case-sensitive match
ilike(a, pattern)
Case-insensitive match
inArray(a, arr)
Value in array
isNull(a)
Is null
isUndefined(a)
Is undefined
Sorting and Pagination
useLiveQuery((q) => q.from({ todo: todosCollection }) .orderBy(({ todo }) => todo.createdAt, "desc") .limit(20) .offset(0) )
Select Projection
// Select specific fields useLiveQuery((q) => q.from({ todo: todosCollection }) .select(({ todo }) => ({ id: todo.id, text: todo.text, done: todo.completed, })) )
// Computed fields useLiveQuery((q) => q.from({ todo: todosCollection }) .select(({ todo }) => ({ id: todo.id, displayText: upper(todo.text), isOverdue: lt(todo.dueDate, new Date().toISOString()), })) )
Joins
import { usersCollection } from "@/collections/users"
// Inner join useLiveQuery((q) => q.from({ todo: todosCollection }) .join( { user: usersCollection }, ({ todo, user }) => eq(todo.userId, user.id), "inner" ) .select(({ todo, user }) => ({ id: todo.id, text: todo.text, assignee: user.name, })) )
// Left join (default) useLiveQuery((q) => q.from({ todo: todosCollection }) .leftJoin( { user: usersCollection }, ({ todo, user }) => eq(todo.userId, user.id) ) )
Aggregations
// Group by with aggregates useLiveQuery((q) => q.from({ todo: todosCollection }) .groupBy(({ todo }) => todo.status) .select(({ todo }) => ({ status: todo.status, count: count(todo.id), avgPriority: avg(todo.priority), })) )
// With having clause useLiveQuery((q) => q.from({ order: ordersCollection }) .groupBy(({ order }) => order.customerId) .select(({ order }) => ({ customerId: order.customerId, totalSpent: sum(order.amount), })) .having(({ $selected }) => gt($selected.totalSpent, 1000)) )
Find Single Item
// Returns T | undefined useLiveQuery((q) => q.from({ todo: todosCollection }) .where(({ todo }) => eq(todo.id, todoId)) .findOne() )
Reactive Dependencies
// Re-run query when deps change const [filter, setFilter] = useState("all")
useLiveQuery( (q) => q.from({ todo: todosCollection }) .where(({ todo }) => filter === "all" ? true : eq(todo.status, filter) ), [filter] // Dependency array )
Mutations
Basic Operations
const { collection } = useLiveQuery((q) => q.from({ todo: todosCollection }) )
// Insert collection.insert({ id: crypto.randomUUID(), text: "New todo", completed: false, createdAt: new Date().toISOString(), })
// Insert multiple collection.insert([item1, item2, item3])
// Update (immutable draft pattern) collection.update(todoId, (draft) => { draft.completed = true draft.completedAt = new Date().toISOString() })
// Update multiple collection.update([id1, id2], (drafts) => { drafts.forEach((d) => (d.completed = true)) })
// Delete collection.delete(todoId)
// Delete multiple collection.delete([id1, id2, id3])
Non-Optimistic Mutations
// Skip optimistic update, wait for server collection.insert(item, { optimistic: false }) collection.update(id, updater, { optimistic: false }) collection.delete(id, { optimistic: false })
Custom Optimistic Actions
import { createOptimisticAction } from "@tanstack/react-db"
// Multi-collection or complex mutations
const likePost = createOptimisticAction<string>({
onMutate: (postId) => {
postsCollection.update(postId, (draft) => {
draft.likeCount += 1
draft.likedByMe = true
})
},
mutationFn: async (postId) => {
await fetch(/api/posts/${postId}/like, { method: "POST" })
// Optionally refetch
await postsCollection.utils.refetch()
},
})
// Usage likePost.mutate(postId)
Manual Transactions
import { createTransaction } from "@tanstack/react-db"
const tx = createTransaction({ autoCommit: false, mutationFn: async ({ transaction }) => { // Batch all mutations in single request await fetch("/api/batch", { method: "POST", body: JSON.stringify(transaction.mutations), }) }, })
// Queue mutations tx.mutate(() => { todosCollection.insert(newTodo) todosCollection.update(existingId, (d) => (d.status = "active")) todosCollection.delete(oldId) })
// Commit or rollback await tx.commit() // or tx.rollback()
Paced Mutations (Debounce/Throttle)
import { usePacedMutations, debounceStrategy } from "@tanstack/react-db"
// Debounce rapid updates (e.g., text input)
const { mutate } = usePacedMutations({
onMutate: (value: string) => {
todosCollection.update(todoId, (d) => (d.text = value))
},
mutationFn: async ({ transaction }) => {
const changes = transaction.mutations[0].changes
await fetch(/api/todos/${todoId}, {
method: "PATCH",
body: JSON.stringify(changes),
})
},
strategy: debounceStrategy({ wait: 500 }),
})
// Usage in input <input onChange={(e) => mutate(e.target.value)} />
Provider Setup
// src/main.tsx import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { DBProvider } from "@tanstack/react-db"
const queryClient = new QueryClient()
function App() { return ( <QueryClientProvider client={queryClient}> <DBProvider> <Router /> </DBProvider> </QueryClientProvider> ) }
Electric Backend Setup
Server-Side Transaction ID
// api/todos/route.ts (example with Drizzle) import { db } from "@/db" import { todos } from "@/db/schema" import { sql } from "drizzle-orm"
export async function POST(req: Request) { const data = await req.json()
const result = await db.transaction(async (tx) => { // Insert the todo const [todo] = await tx.insert(todos).values(data).returning()
// Get transaction ID in SAME transaction
const [{ txid }] = await tx.execute(
sql`SELECT pg_current_xact_id()::text as txid`
)
return { todo, txid: parseInt(txid, 10) }
})
return Response.json(result) }
Electric Proxy Route
// api/electric/[...path]/route.ts
export async function GET(req: Request) {
const url = new URL(req.url)
const electricUrl = ${process.env.ELECTRIC_URL}${url.pathname}${url.search}
return fetch(electricUrl, {
headers: { Authorization: Bearer ${process.env.ELECTRIC_TOKEN} },
})
}
Utility Methods
// Refetch collection data await collection.utils.refetch()
// Direct writes (bypass optimistic state) collection.utils.writeInsert(item) collection.utils.writeUpdate(item) collection.utils.writeDelete(id) collection.utils.writeUpsert(item)
// Batch direct writes collection.utils.writeBatch(() => { collection.utils.writeInsert(item1) collection.utils.writeDelete(id2) })
// Wait for Electric sync (with txid) await collection.utils.awaitTxId(txid, 30000)
// Wait for custom match await collection.utils.awaitMatch( (msg) => msg.value.id === expectedId, 5000 )
Gotchas and Tips
-
Queries run client-side: TanStack DB is NOT an ORM - queries run locally against collections, not against a database
-
Sub-millisecond updates: Uses differential dataflow - only recalculates affected parts of queries
-
Transaction IDs matter: With Electric, always get pg_current_xact_id() in the SAME transaction as mutations
-
Sync modes: Use "eager" for small datasets, "on-demand" for large, "progressive" for collaborative apps
-
Optimistic by default: All mutations apply instantly; use { optimistic: false } for server-validated operations
-
Fine-grained reactivity: Only components using changed data re-render
-
Mutation merging: Rapid updates merge automatically (insert+update→insert, update+update→merged)
-
Collection = complete state: Empty array from queryFn clears the collection
Common Patterns
Loading States
function TodoList() { const { data, isLoading, isPending } = useLiveQuery((q) => q.from({ todo: todosCollection }) )
if (isLoading) return <Skeleton /> if (!data?.length) return <EmptyState />
return <List items={data} /> }
Mutation with Feedback
function TodoItem({ todo }: { todo: Todo }) { const { collection } = useLiveQuery((q) => q.from({ todo: todosCollection }) )
const toggle = async () => { const tx = collection.update(todo.id, (d) => { d.completed = !d.completed })
try {
await tx.isPersisted.promise
toast.success("Saved!")
} catch (err) {
toast.error("Failed to save")
// Optimistic update already rolled back
}
}
return <Checkbox checked={todo.completed} onChange={toggle} /> }
Derived/Computed Collections
// Create a "view" with live query const completedTodos = useLiveQuery((q) => q.from({ todo: todosCollection }) .where(({ todo }) => eq(todo.completed, true)) .orderBy(({ todo }) => todo.completedAt, "desc") )