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
bash
1# Core + React
2npm install @tanstack/react-db @tanstack/db
3
4# With TanStack Query (REST APIs)
5npm install @tanstack/query-db-collection @tanstack/react-query
6
7# With ElectricSQL (Postgres sync)
8npm install @tanstack/electric-db-collection
9
10# With RxDB (offline-first)
11npm install @tanstack/rxdb-db-collection rxdb
Collections
Query Collection (REST API)
typescript
1// src/collections/todos.ts
2import { createCollection } from "@tanstack/react-db"
3import { queryCollectionOptions } from "@tanstack/query-db-collection"
4import { queryClient } from "@/lib/db"
5
6export interface Todo {
7 id: string
8 text: string
9 completed: boolean
10 createdAt: string
11}
12
13export const todosCollection = createCollection(
14 queryCollectionOptions({
15 queryKey: ["todos"],
16 queryFn: async () => {
17 const res = await fetch("/api/todos")
18 return res.json() as Promise<Todo[]>
19 },
20 queryClient,
21 getKey: (item) => item.id,
22
23 // Persistence handlers
24 onInsert: async ({ transaction }) => {
25 const items = transaction.mutations.map((m) => m.modified)
26 await fetch("/api/todos", {
27 method: "POST",
28 body: JSON.stringify(items),
29 })
30 },
31
32 onUpdate: async ({ transaction }) => {
33 await Promise.all(
34 transaction.mutations.map((m) =>
35 fetch(`/api/todos/${m.key}`, {
36 method: "PATCH",
37 body: JSON.stringify(m.changes),
38 })
39 )
40 )
41 },
42
43 onDelete: async ({ transaction }) => {
44 await Promise.all(
45 transaction.mutations.map((m) =>
46 fetch(`/api/todos/${m.key}`, { method: "DELETE" })
47 )
48 )
49 },
50 })
51)
Electric Collection (Postgres Sync)
typescript
1// src/collections/todos.ts
2import { createCollection } from "@tanstack/react-db"
3import { electricCollectionOptions } from "@tanstack/electric-db-collection"
4
5export const todosCollection = createCollection(
6 electricCollectionOptions({
7 id: "todos",
8 shapeOptions: {
9 url: "/api/electric/todos", // Proxy to Electric
10 },
11 getKey: (item) => item.id,
12
13 // Use transaction ID for sync confirmation
14 onInsert: async ({ transaction }) => {
15 const item = transaction.mutations[0].modified
16 const res = await fetch("/api/todos", {
17 method: "POST",
18 body: JSON.stringify(item),
19 })
20 const { txid } = await res.json()
21 return { txid } // Electric waits for this txid
22 },
23
24 onUpdate: async ({ transaction }) => {
25 const { key, changes } = transaction.mutations[0]
26 const res = await fetch(`/api/todos/${key}`, {
27 method: "PATCH",
28 body: JSON.stringify(changes),
29 })
30 const { txid } = await res.json()
31 return { txid }
32 },
33
34 onDelete: async ({ transaction }) => {
35 const { key } = transaction.mutations[0]
36 const res = await fetch(`/api/todos/${key}`, { method: "DELETE" })
37 const { txid } = await res.json()
38 return { txid }
39 },
40 })
41)
Sync Modes
typescript
1// Eager (default): Load all upfront - best for <10k rows
2electricCollectionOptions({
3 sync: { mode: "eager" },
4 // ...
5})
6
7// On-demand: Load only what queries request - best for >50k rows
8electricCollectionOptions({
9 sync: { mode: "on-demand" },
10 // ...
11})
12
13// Progressive: Instant query results + background full sync
14electricCollectionOptions({
15 sync: { mode: "progressive" },
16 // ...
17})
Live Queries
Basic Query
tsx
1import { useLiveQuery } from "@tanstack/react-db"
2import { todosCollection } from "@/collections/todos"
3
4function TodoList() {
5 const { data: todos, isLoading } = useLiveQuery((q) =>
6 q.from({ todo: todosCollection })
7 )
8
9 if (isLoading) return <div>Loading...</div>
10
11 return (
12 <ul>
13 {todos?.map((todo) => (
14 <li key={todo.id}>{todo.text}</li>
15 ))}
16 </ul>
17 )
18}
Filtering with Where
typescript
1import { eq, gt, and, or, like, inArray } from "@tanstack/react-db"
2
3// Simple equality
4useLiveQuery((q) =>
5 q.from({ todo: todosCollection })
6 .where(({ todo }) => eq(todo.completed, false))
7)
8
9// Multiple conditions
10useLiveQuery((q) =>
11 q.from({ todo: todosCollection })
12 .where(({ todo }) =>
13 and(
14 eq(todo.completed, false),
15 gt(todo.priority, 5)
16 )
17 )
18)
19
20// OR conditions
21useLiveQuery((q) =>
22 q.from({ todo: todosCollection })
23 .where(({ todo }) =>
24 or(
25 eq(todo.status, "urgent"),
26 eq(todo.status, "high")
27 )
28 )
29)
30
31// String matching
32useLiveQuery((q) =>
33 q.from({ todo: todosCollection })
34 .where(({ todo }) => like(todo.text, "%meeting%"))
35)
36
37// In array
38useLiveQuery((q) =>
39 q.from({ todo: todosCollection })
40 .where(({ todo }) => inArray(todo.id, ["1", "2", "3"]))
41)
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
typescript
1useLiveQuery((q) =>
2 q.from({ todo: todosCollection })
3 .orderBy(({ todo }) => todo.createdAt, "desc")
4 .limit(20)
5 .offset(0)
6)
Select Projection
typescript
1// Select specific fields
2useLiveQuery((q) =>
3 q.from({ todo: todosCollection })
4 .select(({ todo }) => ({
5 id: todo.id,
6 text: todo.text,
7 done: todo.completed,
8 }))
9)
10
11// Computed fields
12useLiveQuery((q) =>
13 q.from({ todo: todosCollection })
14 .select(({ todo }) => ({
15 id: todo.id,
16 displayText: upper(todo.text),
17 isOverdue: lt(todo.dueDate, new Date().toISOString()),
18 }))
19)
Joins
typescript
1import { usersCollection } from "@/collections/users"
2
3// Inner join
4useLiveQuery((q) =>
5 q.from({ todo: todosCollection })
6 .join(
7 { user: usersCollection },
8 ({ todo, user }) => eq(todo.userId, user.id),
9 "inner"
10 )
11 .select(({ todo, user }) => ({
12 id: todo.id,
13 text: todo.text,
14 assignee: user.name,
15 }))
16)
17
18// Left join (default)
19useLiveQuery((q) =>
20 q.from({ todo: todosCollection })
21 .leftJoin(
22 { user: usersCollection },
23 ({ todo, user }) => eq(todo.userId, user.id)
24 )
25)
Aggregations
typescript
1// Group by with aggregates
2useLiveQuery((q) =>
3 q.from({ todo: todosCollection })
4 .groupBy(({ todo }) => todo.status)
5 .select(({ todo }) => ({
6 status: todo.status,
7 count: count(todo.id),
8 avgPriority: avg(todo.priority),
9 }))
10)
11
12// With having clause
13useLiveQuery((q) =>
14 q.from({ order: ordersCollection })
15 .groupBy(({ order }) => order.customerId)
16 .select(({ order }) => ({
17 customerId: order.customerId,
18 totalSpent: sum(order.amount),
19 }))
20 .having(({ $selected }) => gt($selected.totalSpent, 1000))
21)
Find Single Item
typescript
1// Returns T | undefined
2useLiveQuery((q) =>
3 q.from({ todo: todosCollection })
4 .where(({ todo }) => eq(todo.id, todoId))
5 .findOne()
6)
Reactive Dependencies
typescript
1// Re-run query when deps change
2const [filter, setFilter] = useState("all")
3
4useLiveQuery(
5 (q) =>
6 q.from({ todo: todosCollection })
7 .where(({ todo }) =>
8 filter === "all" ? true : eq(todo.status, filter)
9 ),
10 [filter] // Dependency array
11)
Mutations
Basic Operations
typescript
1const { collection } = useLiveQuery((q) =>
2 q.from({ todo: todosCollection })
3)
4
5// Insert
6collection.insert({
7 id: crypto.randomUUID(),
8 text: "New todo",
9 completed: false,
10 createdAt: new Date().toISOString(),
11})
12
13// Insert multiple
14collection.insert([item1, item2, item3])
15
16// Update (immutable draft pattern)
17collection.update(todoId, (draft) => {
18 draft.completed = true
19 draft.completedAt = new Date().toISOString()
20})
21
22// Update multiple
23collection.update([id1, id2], (drafts) => {
24 drafts.forEach((d) => (d.completed = true))
25})
26
27// Delete
28collection.delete(todoId)
29
30// Delete multiple
31collection.delete([id1, id2, id3])
Non-Optimistic Mutations
typescript
1// Skip optimistic update, wait for server
2collection.insert(item, { optimistic: false })
3collection.update(id, updater, { optimistic: false })
4collection.delete(id, { optimistic: false })
Custom Optimistic Actions
typescript
1import { createOptimisticAction } from "@tanstack/react-db"
2
3// Multi-collection or complex mutations
4const likePost = createOptimisticAction<string>({
5 onMutate: (postId) => {
6 postsCollection.update(postId, (draft) => {
7 draft.likeCount += 1
8 draft.likedByMe = true
9 })
10 },
11 mutationFn: async (postId) => {
12 await fetch(`/api/posts/${postId}/like`, { method: "POST" })
13 // Optionally refetch
14 await postsCollection.utils.refetch()
15 },
16})
17
18// Usage
19likePost.mutate(postId)
Manual Transactions
typescript
1import { createTransaction } from "@tanstack/react-db"
2
3const tx = createTransaction({
4 autoCommit: false,
5 mutationFn: async ({ transaction }) => {
6 // Batch all mutations in single request
7 await fetch("/api/batch", {
8 method: "POST",
9 body: JSON.stringify(transaction.mutations),
10 })
11 },
12})
13
14// Queue mutations
15tx.mutate(() => {
16 todosCollection.insert(newTodo)
17 todosCollection.update(existingId, (d) => (d.status = "active"))
18 todosCollection.delete(oldId)
19})
20
21// Commit or rollback
22await tx.commit()
23// or
24tx.rollback()
Paced Mutations (Debounce/Throttle)
typescript
1import { usePacedMutations, debounceStrategy } from "@tanstack/react-db"
2
3// Debounce rapid updates (e.g., text input)
4const { mutate } = usePacedMutations({
5 onMutate: (value: string) => {
6 todosCollection.update(todoId, (d) => (d.text = value))
7 },
8 mutationFn: async ({ transaction }) => {
9 const changes = transaction.mutations[0].changes
10 await fetch(`/api/todos/${todoId}`, {
11 method: "PATCH",
12 body: JSON.stringify(changes),
13 })
14 },
15 strategy: debounceStrategy({ wait: 500 }),
16})
17
18// Usage in input
19<input onChange={(e) => mutate(e.target.value)} />
Provider Setup
tsx
1// src/main.tsx
2import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
3import { DBProvider } from "@tanstack/react-db"
4
5const queryClient = new QueryClient()
6
7function App() {
8 return (
9 <QueryClientProvider client={queryClient}>
10 <DBProvider>
11 <Router />
12 </DBProvider>
13 </QueryClientProvider>
14 )
15}
Electric Backend Setup
Server-Side Transaction ID
typescript
1// api/todos/route.ts (example with Drizzle)
2import { db } from "@/db"
3import { todos } from "@/db/schema"
4import { sql } from "drizzle-orm"
5
6export async function POST(req: Request) {
7 const data = await req.json()
8
9 const result = await db.transaction(async (tx) => {
10 // Insert the todo
11 const [todo] = await tx.insert(todos).values(data).returning()
12
13 // Get transaction ID in SAME transaction
14 const [{ txid }] = await tx.execute(
15 sql`SELECT pg_current_xact_id()::text as txid`
16 )
17
18 return { todo, txid: parseInt(txid, 10) }
19 })
20
21 return Response.json(result)
22}
Electric Proxy Route
typescript
1// api/electric/[...path]/route.ts
2export async function GET(req: Request) {
3 const url = new URL(req.url)
4 const electricUrl = `${process.env.ELECTRIC_URL}${url.pathname}${url.search}`
5
6 return fetch(electricUrl, {
7 headers: { Authorization: `Bearer ${process.env.ELECTRIC_TOKEN}` },
8 })
9}
Utility Methods
typescript
1// Refetch collection data
2await collection.utils.refetch()
3
4// Direct writes (bypass optimistic state)
5collection.utils.writeInsert(item)
6collection.utils.writeUpdate(item)
7collection.utils.writeDelete(id)
8collection.utils.writeUpsert(item)
9
10// Batch direct writes
11collection.utils.writeBatch(() => {
12 collection.utils.writeInsert(item1)
13 collection.utils.writeDelete(id2)
14})
15
16// Wait for Electric sync (with txid)
17await collection.utils.awaitTxId(txid, 30000)
18
19// Wait for custom match
20await collection.utils.awaitMatch(
21 (msg) => msg.value.id === expectedId,
22 5000
23)
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
tsx
1function TodoList() {
2 const { data, isLoading, isPending } = useLiveQuery((q) =>
3 q.from({ todo: todosCollection })
4 )
5
6 if (isLoading) return <Skeleton />
7 if (!data?.length) return <EmptyState />
8
9 return <List items={data} />
10}
Mutation with Feedback
tsx
1function TodoItem({ todo }: { todo: Todo }) {
2 const { collection } = useLiveQuery((q) =>
3 q.from({ todo: todosCollection })
4 )
5
6 const toggle = async () => {
7 const tx = collection.update(todo.id, (d) => {
8 d.completed = !d.completed
9 })
10
11 try {
12 await tx.isPersisted.promise
13 toast.success("Saved!")
14 } catch (err) {
15 toast.error("Failed to save")
16 // Optimistic update already rolled back
17 }
18 }
19
20 return <Checkbox checked={todo.completed} onChange={toggle} />
21}
Derived/Computed Collections
tsx
1// Create a "view" with live query
2const completedTodos = useLiveQuery((q) =>
3 q.from({ todo: todosCollection })
4 .where(({ todo }) => eq(todo.completed, true))
5 .orderBy(({ todo }) => todo.completedAt, "desc")
6)