
Tanstack Db
- 96 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
tanstack-db is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tanstack-db
- AI & Agent Building
- AI-coding skill
Tanstack Db by the numbers
- 96 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,561 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill tanstack-dbAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 96 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
TanStack DB
Overview
TanStack DB is a reactive client store built on differential dataflow that extends TanStack Query with collections, live queries, and optimistic mutations. It normalizes data into typed collections, enables sub-millisecond cross-collection queries, and provides instant optimistic updates with automatic rollback on failure.
When to use: Reactive UIs needing local-first data, cross-collection joins with live updates, optimistic mutations with automatic sync, real-time sync via ElectricSQL or other backends, apps that outgrow TanStack Query's per-query caching model.
When NOT to use: Simple fetch-and-display (TanStack Query alone suffices), server-components-only apps, purely synchronous local state (useState/Zustand), GraphQL with normalized caching (Apollo/urql).
TanStack DB is currently in beta. APIs may change between releases.
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Create collection | createCollection(queryCollectionOptions({...})) | Define typed set of objects with getKey |
| Live query (React) | useLiveQuery((q) => q.from({...}).where(...)) | Auto-updates when underlying data changes |
| Filter | .where(({ t }) => eq(t.field, value)) | Supports eq, gt, lt, like, and, or, not |
| Select fields | .select(({ t }) => ({ id: t.id, name: t.name })) | Project specific fields from collections |
| Order results | .orderBy(({ t }) => t.field, 'asc') | Sort ascending or descending |
| Join collections | .join({ b: collB }, ({ a, b }) => eq(...), 'inner') | Cross-collection joins with type safety |
| Group and aggregate | .groupBy(...).select(({ t }) => ({ count: count(t.id) })) | Supports count, sum, avg, min, max |
| Insert | collection.insert({ ...data }) | Optimistic insert, syncs via onInsert handler |
| Update | collection.update(key, (draft) => { ... }) | Immer-style draft mutation, syncs via onUpdate |
| Delete | collection.delete(key) | Optimistic delete, syncs via onDelete handler |
| Electric sync | electricCollectionOptions({ shapeOptions: {...} }) | Real-time Postgres sync via ElectricSQL |
| Live query coll. | liveQueryCollectionOptions({ query }) | Derived collection from live query definition |
| Local storage | localStorageCollectionOptions({...}) | Persistent local data, syncs across tabs |
Sync Modes (v0.5+)
| Mode | Behavior | Use Case |
|---|---|---|
| Eager (default) | Loads all records on collection init | Small datasets (< 1k rows) |
| On-demand | Loads only what queries request (predicate pushdown) | Large datasets, selective loading |
| Progressive | Fast first paint, full dataset syncs in background | Best of both, scales to 100k+ |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Using TanStack Query directly for local state | Use collections with live queries for reactive local data |
Forgetting getKey in collection config | Always provide getKey to identify items uniquely |
| Not providing persistence handlers | Define onInsert/onUpdate/onDelete to sync with server |
Using useQuery instead of useLiveQuery | useLiveQuery provides reactive cross-collection queries |
| Creating collections inside components | Define collections at module scope, outside components |
Importing from @tanstack/db in React apps | Import from @tanstack/react-db (re-exports core) |
| Expecting automatic server sync without config | Collections require explicit persistence handlers for sync |
| Not installing collection type package | Install @tanstack/query-db-collection for REST API usage |
Delegation
If the tanstack-query skill is available, delegate TanStack Query-specific patterns (query keys, cache invalidation, SSR) to it.Otherwise, recommend: npx skills add oakoss/agent-skills --skill tanstack-queryIf the electricsql skill is available, delegate ElectricSQL setup, shapes, auth proxy, and write patterns to it.Otherwise, recommend: pnpm dlx skills add oakoss/agent-skills -s electricsql -a claude-code -yIf the local-first skill is available, delegate architecture decisions, sync engine comparison, and conflict resolution to it.Otherwise, recommend: pnpm dlx skills add oakoss/agent-skills -s local-first -a claude-code -y- Query pattern discovery: Use
Exploreagent - Architecture review: Use
Taskagent
References
- Setup, installation, and collection configuration
- Live queries, filtering, joins, and aggregations
- Optimistic mutations, persistence handlers, and sync patterns
- ElectricSQL integration, electric collections, and txid patterns
- Error class hierarchy, transaction states, and rollback patterns
- Local-only and localStorage collections with cross-tab sync
- Cross-collection transactions, joins, batching, and lifecycle
ElectricSQL Integration
Installation
npm install @tanstack/react-db @tanstack/electric-db-collectionThe Electric collection package connects TanStack DB to ElectricSQL's shape-based sync. Data flows from Postgres → Electric → ShapeStream → collection, with live queries reacting to every change.
Basic Electric Collection
import { createCollection } from '@tanstack/react-db';
import { electricCollectionOptions } from '@tanstack/electric-db-collection';
type Todo = {
id: string;
title: string;
completed: boolean;
user_id: string;
created_at: string;
};
const todoCollection = createCollection(
electricCollectionOptions({
id: 'todos',
getKey: (row: Todo) => row.id,
shapeOptions: {
url: 'http://localhost:3000/v1/shape',
params: {
table: 'todos',
},
},
}),
);electricCollectionOptions API
| Option | Required | Type | Description |
|---|---|---|---|
id | Yes | string | Unique collection identifier |
getKey | Yes | `(row: T) => string \ | number` |
shapeOptions | Yes | ShapeStreamOptions | ElectricSQL shape configuration |
schema | No | ZodSchema | Runtime validation for incoming rows |
onInsert | No | (ctx: MutationContext<T>) => Promise<TxResult> | Handler for optimistic inserts |
onUpdate | No | (ctx: MutationContext<T>) => Promise<TxResult> | Handler for optimistic updates |
onDelete | No | (ctx: MutationContext<T>) => Promise<TxResult> | Handler for optimistic deletes |
syncMode | No | `'eager' \ | 'on-demand' \ |
Shape Options
The shapeOptions object maps directly to ElectricSQL's ShapeStream configuration:
const filteredCollection = createCollection(
electricCollectionOptions({
id: 'active-todos',
getKey: (row: Todo) => row.id,
shapeOptions: {
url: '/api/shapes/todos',
params: {
table: 'todos',
where: 'completed = false',
columns: 'id,title,completed,created_at',
},
},
}),
);| Shape Param | Description |
|---|---|
table | Postgres table name |
where | SQL where clause for server-side filtering |
columns | Comma-separated column names to sync |
replica | Set to 'full' for complete row data on updates |
log | Set to 'changes_only' to skip initial snapshot |
Write Handlers with txid
Write handlers persist optimistic mutations to your API. Returning { txid } tells TanStack DB to keep the optimistic state until Electric confirms the write has propagated back through the sync stream.
const todoCollection = createCollection(
electricCollectionOptions({
id: 'todos',
getKey: (row: Todo) => row.id,
shapeOptions: {
url: '/api/shapes/todos',
params: { table: 'todos' },
},
onInsert: async ({ transaction }) => {
const newTodo = transaction.mutations[0].modified;
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
const { txid } = await response.json();
return { txid };
},
onUpdate: async ({ transaction }) => {
const { original, modified, changes } = transaction.mutations[0];
const response = await fetch(`/api/todos/${original.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
});
const { txid } = await response.json();
return { txid };
},
onDelete: async ({ transaction }) => {
const key = transaction.mutations[0].key;
const response = await fetch(`/api/todos/${key}`, {
method: 'DELETE',
});
const { txid } = await response.json();
return { txid };
},
}),
);txid Flow
The transaction ID (txid) connects the write path to the read path:
1. Client calls collection.insert(item) — UI updates immediately (optimistic) 2. onInsert handler sends data to your API 3. API writes to Postgres, returns txid (e.g., the LSN or a UUID) 4. Handler returns { txid } to TanStack DB 5. TanStack DB watches the Electric shape stream for this txid 6. When Electric syncs the confirmed row back, optimistic state is replaced with server state 7. If txid is not returned, optimistic state is discarded on next shape sync
Batch Mutations
Write handlers receive all mutations in a transaction, enabling batch operations:
onInsert: async ({ transaction }) => {
const newItems = transaction.mutations.map((m) => m.modified)
const response = await fetch('/api/todos/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: newItems }),
})
const { txid } = await response.json()
return { txid }
},Sync Modes
const eagerCollection = createCollection(
electricCollectionOptions({
id: 'small-dataset',
getKey: (row: Todo) => row.id,
syncMode: 'eager',
shapeOptions: {
url: '/api/shapes/todos',
params: { table: 'todos' },
},
}),
);
const onDemandCollection = createCollection(
electricCollectionOptions({
id: 'large-dataset',
getKey: (row: Item) => row.id,
syncMode: 'on-demand',
shapeOptions: {
url: '/api/shapes/items',
params: { table: 'items' },
},
}),
);
const progressiveCollection = createCollection(
electricCollectionOptions({
id: 'progressive-dataset',
getKey: (row: Item) => row.id,
syncMode: 'progressive',
shapeOptions: {
url: '/api/shapes/items',
params: { table: 'items' },
},
}),
);| Mode | Initial Load | Best For |
|---|---|---|
eager | All records loaded immediately | Small datasets (< 1k rows) |
on-demand | Only loads what queries request | Large datasets, filtered views |
progressive | Fast first paint, full sync next | Balanced UX with large datasets |
Live Queries with Electric Collections
Electric collections work with all TanStack DB live query features:
import { useLiveQuery } from '@tanstack/react-db';
import { eq, and, gt } from '@tanstack/db/query';
function RecentActiveTodos() {
const { data: todos } = useLiveQuery((q) =>
q
.from({ todos: todoCollection })
.where(({ todos: t }) =>
and(eq(t.completed, false), gt(t.created_at, '2024-01-01')),
)
.orderBy(({ todos: t }) => t.created_at, 'desc'),
);
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}Cross-Collection Joins
Join Electric-synced collections with other collection types:
import { useLiveQuery } from '@tanstack/react-db';
import { eq } from '@tanstack/db/query';
function TodosWithUsers() {
const { data } = useLiveQuery((q) =>
q
.from({ todos: todoCollection })
.join(
{ users: userCollection },
({ todos, users }) => eq(todos.user_id, users.id),
'inner',
)
.select(({ todos, users }) => ({
id: todos.id,
title: todos.title,
userName: users.name,
})),
);
return (
<ul>
{data.map((item) => (
<li key={item.id}>
{item.title} — {item.userName}
</li>
))}
</ul>
);
}Error Handling
const todoCollection = createCollection(
electricCollectionOptions({
id: 'todos',
getKey: (row: Todo) => row.id,
shapeOptions: {
url: '/api/shapes/todos',
params: { table: 'todos' },
onError: async (error) => {
if (error instanceof FetchError && error.status === 401) {
const newToken = await refreshAuthToken();
return { headers: { Authorization: `Bearer ${newToken}` } };
}
return {};
},
},
onInsert: async ({ transaction }) => {
try {
const newTodo = transaction.mutations[0].modified;
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
const { txid } = await response.json();
return { txid };
} catch (error) {
throw error;
}
},
}),
);When a write handler throws, TanStack DB automatically rolls back the optimistic mutation, restoring the collection to its pre-mutation state.
Auth Proxy Pattern
In production, route shape requests through your API instead of exposing Electric directly:
const todoCollection = createCollection(
electricCollectionOptions({
id: 'todos',
getKey: (row: Todo) => row.id,
shapeOptions: {
url: '/api/shapes/todos',
params: { table: 'todos' },
headers: {
Authorization: async () => `Bearer ${await getAccessToken()}`,
},
},
}),
);The proxy server validates the token and forwards the request to Electric with appropriate where clause filtering per user.
Error Handling
TanStack DB provides a structured error hierarchy for diagnosing mutation failures, schema violations, and query issues. All errors extend TanStackDBError.
Error Class Hierarchy
TanStackDBError (base)
├── MissingInsertHandlerError
├── MissingUpdateHandlerError
├── MissingDeleteHandlerError
├── TransactionNotPendingMutateError
├── TransactionNotPendingCommitError
├── SchemaValidationError
├── DuplicateKeyError
├── UpdateKeyNotFoundError
├── DeleteKeyNotFoundError
├── KeyUpdateNotAllowedError
├── NonRetriableError
├── InvalidWhereExpressionError
└── DuplicateDbInstanceErrorTransaction States
Transactions move through a defined lifecycle:
| State | Description |
|---|---|
pending | Transaction created, accepting mutations via mutate() |
persisting | Committed and running persistence handlers |
completed | All handlers succeeded, changes confirmed |
failed | A handler threw, changes rolled back |
import { createTransaction } from '@tanstack/db';
const tx = createTransaction({
mutationFn: async ({ transaction }) => {
await api.saveBatch(transaction.mutations);
},
});
tx.mutate(() => {
todoCollection.insert({ id: '1', text: 'Task', completed: false });
});
tx.commit();
const result = await tx.isPersisted.promise;
if (result.status === 'completed') {
console.log('Transaction persisted');
} else {
console.error('Transaction failed:', result.error);
}Missing Handler Errors
Thrown when a mutation targets a collection that lacks the corresponding persistence handler:
const collection = createCollection(
queryCollectionOptions({
queryKey: ['items'],
queryFn: fetchItems,
getKey: (item) => item.id,
// No onInsert defined
}),
);
// MissingInsertHandlerError: No onInsert handler for collection
collection.insert({ id: '1', name: 'Item' });| Error | Trigger |
|---|---|
MissingInsertHandlerError | collection.insert() without onInsert |
MissingUpdateHandlerError | collection.update() without onUpdate |
MissingDeleteHandlerError | collection.delete() without onDelete |
These errors indicate the collection is missing a persistence handler. For local-only collections that do not sync, use localOnlyCollectionOptions instead.
Schema Validation Errors
SchemaValidationError fires when data fails schema validation. The issues array contains details per field:
import { z } from 'zod';
const todoSchema = z.object({
id: z.string().uuid(),
text: z.string().min(1),
completed: z.boolean(),
});
const collection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
getKey: (item) => item.id,
schema: todoSchema,
onInsert: async ({ transaction }) => {
await api.createTodos(transaction.mutations.map((m) => m.modified));
},
}),
);
try {
// Fails: text is empty string, id is not a UUID
collection.insert({ id: 'bad', text: '', completed: false });
} catch (error) {
if (error instanceof SchemaValidationError) {
console.error(error.type); // 'insert' | 'update'
for (const issue of error.issues) {
console.error(issue.path, issue.message);
}
}
}Key Errors
| Error | Cause |
|---|---|
DuplicateKeyError | Inserting an item with a key that already exists |
UpdateKeyNotFoundError | Updating an item with a key not in the collection |
DeleteKeyNotFoundError | Deleting an item with a key not in the collection |
KeyUpdateNotAllowedError | Changing the key field inside an update draft |
// DuplicateKeyError
todoCollection.insert({ id: 'existing-id', text: 'Dup', completed: false });
// UpdateKeyNotFoundError
todoCollection.update('nonexistent-id', (draft) => {
draft.text = 'Updated';
});
// KeyUpdateNotAllowedError
todoCollection.update('todo-1', (draft) => {
draft.id = 'new-id'; // Cannot change key field
});NonRetriableError
Wrap errors in NonRetriableError inside persistence handlers to signal that the operation should not be retried. The transaction fails immediately and rolls back:
import { NonRetriableError } from '@tanstack/db';
const collection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(transaction.mutations.map((m) => m.modified)),
});
if (response.status === 409) {
throw new NonRetriableError('Conflict: item already exists on server');
}
if (!response.ok) {
// Regular errors may be retried
throw new Error(`Server error: ${response.status}`);
}
},
}),
);Handler Error Handling and Rollback
When a persistence handler throws, the transaction enters the failed state and all optimistic changes roll back:
const collection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
getKey: (item) => item.id,
onUpdate: async ({ transaction }) => {
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes,
}));
const response = await api.updateTodos(updates);
if (!response.ok) {
// Throwing triggers automatic rollback of all mutations in this transaction
throw new Error('Update failed');
}
},
}),
);Awaiting Transaction Results
Use tx.isPersisted.promise to determine whether a transaction succeeded or failed:
const tx = createTransaction({
mutationFn: async ({ transaction }) => {
await api.saveBatch(transaction.mutations);
},
});
tx.mutate(() => {
todoCollection.insert({ id: '1', text: 'New', completed: false });
todoCollection.update('2', (draft) => {
draft.completed = true;
});
});
tx.commit();
try {
await tx.isPersisted.promise;
showToast('Changes saved');
} catch (error) {
showToast('Save failed, changes reverted');
}Transaction State Errors
| Error | Cause |
|---|---|
TransactionNotPendingMutateError | Calling tx.mutate() after commit/rollback |
TransactionNotPendingCommitError | Calling tx.commit() after commit/rollback |
const tx = createTransaction({
mutationFn: async ({ transaction }) => {
await api.save(transaction.mutations);
},
});
tx.mutate(() => {
todoCollection.insert({ id: '1', text: 'Task', completed: false });
});
tx.commit();
// TransactionNotPendingMutateError: transaction already committed
tx.mutate(() => {
todoCollection.insert({ id: '2', text: 'Another', completed: false });
});InvalidWhereExpressionError
Thrown when using JavaScript equality operators instead of TanStack DB filter functions in where clauses:
import { useLiveQuery } from '@tanstack/react-db';
import { eq } from '@tanstack/db';
// WRONG: uses JavaScript === (always returns boolean, not a filter expression)
const result = useLiveQuery((q) =>
q.from({ todo: todoCollection }).where(({ todo }) => todo.status === 'done'),
);
// CORRECT: uses eq() filter function
const result = useLiveQuery((q) =>
q.from({ todo: todoCollection }).where(({ todo }) => eq(todo.status, 'done')),
);DuplicateDbInstanceError
Thrown when multiple instances of @tanstack/db are loaded simultaneously, typically caused by bundler misconfiguration or duplicate dependencies:
DuplicateDbInstanceError: Multiple instances of @tanstack/db detectedFix by deduplicating the package in your dependency tree:
pnpm dedupe @tanstack/dbLive Queries
Live queries run reactively against collections. They automatically update when underlying data changes, powered by differential dataflow for sub-millisecond performance.
Framework adapters: @tanstack/react-db, @tanstack/vue-db, @tanstack/svelte-db (Svelte 5 runes required).
Return Shape
useLiveQuery returns an object with reactive state:
| Property | Type | Description |
|---|---|---|
data | TResult[] | The query results array |
isLoading | boolean | true while the collection is loading |
isReady | boolean | true after data has successfully loaded |
isError | boolean | true if an error occurred |
isIdle | boolean | true when not loading or errored |
isCleanedUp | boolean | true if the collection has been cleaned up |
isEnabled | boolean | true when the live query is enabled |
status | CollectionStatus | Current status: 'loading', 'success', 'error' |
state | Map<TKey, TResult> | Map of the current collection state by key |
collection | Collection | The underlying live query collection instance |
Basic Live Query (React)
import { useLiveQuery } from '@tanstack/react-db';
import { eq } from '@tanstack/db';
function Todos() {
const {
data: todos,
isLoading,
isError,
status,
} = useLiveQuery((q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.completed, false))
.orderBy(({ todo }) => todo.createdAt, 'desc'),
);
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {status}</div>;
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}Expression Functions
All expression functions are imported from @tanstack/db:
import {
eq,
gt,
gte,
lt,
lte,
like,
ilike,
inArray,
and,
or,
not,
} from '@tanstack/db';
eq(user.id, 1);
gt(user.age, 18);
gte(user.age, 18);
lt(user.age, 65);
lte(user.age, 65);
like(user.name, 'John%');
ilike(user.name, 'john%');
inArray(user.id, [1, 2, 3]);
and(condition1, condition2);
or(condition1, condition2);
not(condition);Selecting Fields
Project specific fields to reduce data passed to components:
const { data: todos } = useLiveQuery((q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.completed, false))
.orderBy(({ todo }) => todo.created_at, 'asc')
.select(({ todo }) => ({
id: todo.id,
text: todo.text,
})),
);Combining Filters
const { data: results } = useLiveQuery((q) =>
q
.from({ user: userCollection })
.where(({ user }) =>
and(
gte(user.age, 18),
lt(user.age, 65),
or(eq(user.role, 'admin'), eq(user.role, 'editor')),
),
),
);Cross-Collection Joins
Join multiple collections with type-safe conditions:
import { useLiveQuery } from '@tanstack/react-db';
import { eq } from '@tanstack/db';
function TodosWithLists() {
const { data: todos } = useLiveQuery((q) =>
q
.from({ todos: todoCollection })
.join(
{ lists: listCollection },
({ todos, lists }) => eq(lists.id, todos.listId),
'inner',
)
.where(({ lists }) => eq(lists.active, true))
.select(({ todos, lists }) => ({
id: todos.id,
title: todos.title,
listName: lists.name,
})),
);
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.title} ({todo.listName})
</li>
))}
</ul>
);
}GroupBy and Aggregations
Group data and compute aggregates using count, sum, avg, min, max:
import { count, sum, avg, min, max } from '@tanstack/db';
import { createCollection } from '@tanstack/react-db';
import { liveQueryCollectionOptions } from '@tanstack/db';
const orderStats = createCollection(
liveQueryCollectionOptions({
query: (q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalOrders: count(order.id),
totalAmount: sum(order.amount),
avgOrderValue: avg(order.amount),
minOrder: min(order.amount),
maxOrder: max(order.amount),
})),
}),
);Filtering Aggregated Results
Use fn.having() to filter after aggregation:
const highValueCustomers = createLiveQueryCollection((q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalSpent: sum(order.amount),
orderCount: count(order.id),
}))
.fn.having(({ $selected }) => {
return $selected.totalSpent > 1000 && $selected.orderCount >= 3;
}),
);Live Query Collections
Create reusable live query definitions as collections using liveQueryCollectionOptions:
import { createCollection, liveQueryCollectionOptions, eq } from '@tanstack/db';
const activeUsers = createCollection(
liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
})),
}),
);Use createLiveQueryCollection for inline creation without options wrapper:
import { createLiveQueryCollection, eq } from '@tanstack/db';
const activeTodos = createLiveQueryCollection((q) =>
q
.from({ todos: todoCollection })
.where(({ todos }) => eq(todos.completed, false)),
);Dependency Arrays
useLiveQuery accepts an optional dependency array as a second argument. When any dependency changes, the query re-executes.
React
import { useLiveQuery } from '@tanstack/react-db';
import { gt } from '@tanstack/db';
function FilteredTodos({ minPriority }: { minPriority: number }) {
const { data: todos } = useLiveQuery(
(q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => gt(todo.priority, minPriority)),
[minPriority],
);
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}Svelte
In Svelte, dependencies are wrapped in getter functions. Avoid destructuring the return value directly as it breaks reactivity -- use dot notation or $derived:
<script>
import { useLiveQuery } from '@tanstack/svelte-db'
import { eq, and } from '@tanstack/db'
let userId = $state(1)
let status = $state('active')
const query = useLiveQuery(
(q) => q.from({ todos: todosCollection })
.where(({ todos }) => and(
eq(todos.userId, userId),
eq(todos.status, status)
)),
[() => userId, () => status]
)
</script>
{#if query.isLoading}
<div>Loading...</div>
{:else}
<ul>
{#each query.data as todo (todo.id)}
<li>{todo.text}</li>
{/each}
</ul>
{/if}Query Method Chain
The query builder follows a SQL-like fluent API:
| Method | Purpose | Required |
|---|---|---|
.from() | Source collection(s) | Yes |
.join() | Join additional collections | No |
.where() | Filter rows | No |
.select() | Project specific fields | No |
.orderBy() | Sort results | No |
.groupBy() | Group for aggregation | No |
.fn.having() | Filter aggregated results | No |
Local and Storage Collections
TanStack DB provides two collection types for data that does not require server sync: local-only (in-memory) and localStorage-persisted collections.
Local-Only Collections
localOnlyCollectionOptions creates an in-memory collection with no persistence. Mutations apply instantly with no async handlers. Data is lost on page refresh.
Basic Setup
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db';
interface FilterState {
id: string;
category: string;
sortBy: string;
ascending: boolean;
}
const filterCollection = createCollection(
localOnlyCollectionOptions<FilterState>({
getKey: (item) => item.id,
initialData: [
{ id: 'main', category: 'all', sortBy: 'createdAt', ascending: false },
],
}),
);Configuration
| Option | Type | Description |
|---|---|---|
getKey | (item: T) => string | Extracts unique key from each item |
schema | Standard Schema V1 | Optional validation (Zod/Valibot/ArkType) |
initialData | T[] | Items to populate the collection with |
Direct Mutations
Local-only collections support the same mutation API as synced collections. Changes apply immediately with no async round-trip:
// Insert
filterCollection.insert({
id: 'secondary',
category: 'active',
sortBy: 'priority',
ascending: true,
});
// Update with Immer-style draft
filterCollection.update('main', (draft) => {
draft.category = 'completed';
draft.sortBy = 'updatedAt';
});
// Delete
filterCollection.delete('secondary');Schema Validation
Local-only collections support Standard Schema V1 (Zod, Valibot, ArkType):
import { z } from 'zod';
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db';
const uiStateSchema = z.object({
id: z.string(),
sidebarOpen: z.boolean(),
theme: z.enum(['light', 'dark', 'system']),
});
type UIState = z.infer<typeof uiStateSchema>;
const uiCollection = createCollection(
localOnlyCollectionOptions<UIState>({
getKey: (item) => item.id,
schema: uiStateSchema,
initialData: [{ id: 'app', sidebarOpen: true, theme: 'system' }],
}),
);Manual Transactions with Local-Only Collections
When using createTransaction with local-only collections, you must call utils.acceptMutations() to apply the changes. Without a persistence handler, there is no automatic commit flow:
import { createTransaction } from '@tanstack/db';
const tx = createTransaction({
mutationFn: async ({ transaction, utils }) => {
// No server call needed — just accept the mutations
utils.acceptMutations(transaction);
},
});
tx.mutate(() => {
filterCollection.update('main', (draft) => {
draft.category = 'active';
});
filterCollection.insert({
id: 'sidebar',
category: 'all',
sortBy: 'name',
ascending: true,
});
});
tx.commit();When to Use Local-Only
- Ephemeral UI state (filter selections, sort order, panel visibility)
- Derived or computed state that does not need persistence
- Temporary data during multi-step workflows
- In-memory caches that rebuild on navigation
localStorage Collections
localStorageCollectionOptions persists data to localStorage and syncs across browser tabs via storage events.
Basic Setup
import { createCollection, localStorageCollectionOptions } from '@tanstack/db';
interface UserPreferences {
id: string;
theme: 'light' | 'dark' | 'system';
fontSize: number;
language: string;
}
const preferencesCollection = createCollection(
localStorageCollectionOptions<UserPreferences>({
storageKey: 'user-preferences',
getKey: (item) => item.id,
initialData: [
{ id: 'prefs', theme: 'system', fontSize: 14, language: 'en' },
],
}),
);Configuration
| Option | Type | Description |
|---|---|---|
storageKey | string | Key used in localStorage.setItem() |
getKey | (item: T) => string | Extracts unique key from each item |
schema | Standard Schema V1 | Optional validation (Zod/Valibot/ArkType) |
initialData | T[] | Default data when storage is empty |
Cross-Tab Sync
Changes in one tab automatically propagate to all other tabs via the browser storage event. No additional configuration is needed:
// Tab 1: update theme
preferencesCollection.update('prefs', (draft) => {
draft.theme = 'dark';
});
// Tab 2: live queries automatically reflect the change
const prefs = useLiveQuery((q) =>
q.from({ p: preferencesCollection }).where(({ p }) => eq(p.id, 'prefs')),
);Size Constraints
localStorage collections work best for small datasets:
| Guideline | Recommendation |
|---|---|
| Item count | Under 100 items |
| Total size | Under 100 KB |
| Item size | Keep individual items small |
| Browser limit | ~5 MB per origin (varies) |
For larger datasets, use a synced collection with a proper backend or consider IndexedDB-based solutions.
When to Use localStorage Collections
- User preferences (theme, language, layout)
- Small persistent state that survives page refresh
- Cross-tab shared state (shopping cart, auth tokens)
- Feature flags or user-specific toggles
Choosing Between Collection Types
| Criteria | Local-Only | localStorage | Synced (query/electric) |
|---|---|---|---|
| Persistence | None (memory only) | Browser localStorage | Server backend |
| Survives refresh | No | Yes | Yes |
| Cross-tab sync | No | Yes (storage events) | Yes (via server) |
| Dataset size | Any (memory-limited) | Small (< 100 items) | Any |
| Server sync | No | No | Yes |
| Persistence handlers | Not needed | Not needed | Required |
| Use case | Ephemeral UI state | Small persistent prefs | Application data |
Multi-Collection Patterns
TanStack DB supports coordinated operations across multiple collections through explicit transactions, cross-collection live query joins, and lifecycle management.
Cross-Collection Transactions
Use createTransaction to group mutations across multiple collections into a single atomic unit. All mutations succeed or all roll back:
import { createTransaction } from '@tanstack/db';
const tx = createTransaction({
mutationFn: async ({ transaction }) => {
await api.createProjectWithTasks({
project: transaction.mutations
.filter((m) => m.collection === 'projects')
.map((m) => m.modified),
tasks: transaction.mutations
.filter((m) => m.collection === 'tasks')
.map((m) => m.modified),
});
},
});
tx.mutate(() => {
projectCollection.insert({
id: 'proj-1',
name: 'New Project',
status: 'active',
});
taskCollection.insert({
id: 'task-1',
projectId: 'proj-1',
title: 'Setup',
done: false,
});
taskCollection.insert({
id: 'task-2',
projectId: 'proj-1',
title: 'Implementation',
done: false,
});
});
tx.commit();
try {
await tx.isPersisted.promise;
} catch {
// All mutations across both collections rolled back
}Auto-Commit Control
By default, transactions auto-commit after each mutate() call. Set autoCommit: false to accumulate mutations across multiple mutate() calls before committing:
const tx = createTransaction({
mutationFn: async ({ transaction }) => {
await api.saveBatch(transaction.mutations);
},
autoCommit: false,
});
// First batch of mutations
tx.mutate(() => {
projectCollection.insert({ id: 'p1', name: 'Alpha', status: 'active' });
});
// Second batch based on user input
tx.mutate(() => {
taskCollection.insert({
id: 't1',
projectId: 'p1',
title: 'First task',
done: false,
});
});
// Commit all accumulated mutations at once
tx.commit();Mutation Merging Rules
When multiple mutations target the same item within a single transaction, TanStack DB merges them:
| First Mutation | Second Mutation | Result |
|---|---|---|
| insert | update | Single insert with updated values |
| insert | delete | Both mutations cancel out (no-op) |
| update | update | Single update with merged changes |
| update | delete | Single delete |
| delete | insert | Single update (delete then re-add) |
const tx = createTransaction({
mutationFn: async ({ transaction }) => {
// transaction.mutations contains merged results
await api.saveBatch(transaction.mutations);
},
autoCommit: false,
});
tx.mutate(() => {
// Insert then immediately update = single insert with final values
todoCollection.insert({ id: '1', text: 'Draft', completed: false });
todoCollection.update('1', (draft) => {
draft.text = 'Final text';
});
});
tx.commit();
// The handler receives one insert mutation with text: 'Final text'Cross-Collection Live Query Joins
Live queries can join data across collections with type-safe results:
Left Join
import { useLiveQuery } from '@tanstack/react-db';
import { eq } from '@tanstack/db';
function ProjectsWithTasks() {
const { rows } = useLiveQuery((q) =>
q
.from({ project: projectCollection })
.join(
{ task: taskCollection },
({ project, task }) => eq(project.id, task.projectId),
'left',
)
.select(({ project, task }) => ({
projectName: project.name,
taskTitle: task.title,
taskDone: task.done,
})),
);
return (
<ul>
{rows.map((row, i) => (
<li key={i}>
{row.projectName}: {row.taskTitle ?? 'No tasks'}
</li>
))}
</ul>
);
}Inner Join
const { rows } = useLiveQuery((q) =>
q
.from({ project: projectCollection })
.join(
{ task: taskCollection },
({ project, task }) => eq(project.id, task.projectId),
'inner',
)
.where(({ task }) => eq(task.done, false))
.orderBy(({ project }) => project.name, 'asc'),
);All-or-Nothing Rollback
When a cross-collection transaction fails, all mutations across all collections roll back atomically:
const tx = createTransaction({
mutationFn: async ({ transaction }) => {
// If this throws, both the project insert and task inserts revert
await api.createProjectWithTasks(transaction.mutations);
},
});
tx.mutate(() => {
projectCollection.insert({ id: 'p1', name: 'Project', status: 'active' });
taskCollection.insert({
id: 't1',
projectId: 'p1',
title: 'Task A',
done: false,
});
taskCollection.insert({
id: 't2',
projectId: 'p1',
title: 'Task B',
done: false,
});
});
tx.commit();Chunked Batching for Provider Limits
When a provider has request size limits, chunk mutations into smaller batches within the persistence handler:
function chunk<T>(arr: Array<T>, size: number): Array<Array<T>> {
const chunks: Array<Array<T>> = [];
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size));
}
return chunks;
}
const collection = createCollection(
queryCollectionOptions({
queryKey: ['items'],
queryFn: fetchItems,
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const batches = chunk(transaction.mutations, 25);
for (const batch of batches) {
await api.createItems(batch.map((m) => m.modified));
}
},
}),
);Paced Mutations for Rate-Limited Providers
Add delays between batches when the backend enforces rate limits:
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const collection = createCollection(
queryCollectionOptions({
queryKey: ['items'],
queryFn: fetchItems,
getKey: (item) => item.id,
onUpdate: async ({ transaction }) => {
const batches = chunk(transaction.mutations, 10);
for (let i = 0; i < batches.length; i++) {
if (i > 0) await delay(100);
await api.updateItems(
batches[i].map((m) => ({ id: m.key, changes: m.changes })),
);
}
},
}),
);Collection Lifecycle
Lazy Initialization
Collections initialize lazily when first accessed by a live query or mutation. No data is fetched until the collection is used.
Garbage Collection
Collections support automatic cleanup via gcTime. When no live queries reference a collection, it is garbage collected after the specified duration:
const collection = createCollection(
queryCollectionOptions({
queryKey: ['items'],
queryFn: fetchItems,
getKey: (item) => item.id,
gcTime: 5 * 60 * 1000, // 5 minutes after last subscriber
}),
);Manual Cleanup
Call cleanup() to immediately dispose of a collection and release its resources:
// Tear down collection manually
collection.cleanup();Use manual cleanup for collections tied to a specific view or workflow that should not persist in memory after navigation.
Mutations
TanStack DB mutations are optimistic by default. Changes apply instantly to the local collection and UI, then sync to the server via persistence handlers. If the server request fails, changes roll back automatically.
Insert
function AddTodo() {
const addTodo = () => {
todoCollection.insert({
id: crypto.randomUUID(),
text: 'New todo',
completed: false,
createdAt: new Date(),
});
};
return <button onClick={addTodo}>Add Todo</button>;
}Update
Updates use an Immer-style draft pattern for immutable mutations:
function ToggleTodo({ todo }: { todo: Todo }) {
const toggleComplete = () => {
todoCollection.update(todo.id, (draft) => {
draft.completed = !draft.completed;
});
};
const updateText = (newText: string) => {
todoCollection.update(todo.id, (draft) => {
draft.text = newText;
});
};
return (
<div>
<button onClick={toggleComplete}>Toggle</button>
<button onClick={() => updateText('Updated!')}>Edit</button>
</div>
);
}Delete
function DeleteTodo({ todoId }: { todoId: string }) {
const removeTodo = () => {
todoCollection.delete(todoId);
};
return <button onClick={removeTodo}>Delete</button>;
}Persistence Handlers
Persistence handlers define how mutations sync to the server. Without them, mutations are local-only.
Single Handler (onUpdate)
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,
onUpdate: async ({ transaction }) => {
const { original, modified } = transaction.mutations[0];
await fetch(`/api/todos/${original.id}`, {
method: 'PUT',
body: JSON.stringify(modified),
});
},
}),
);Full CRUD Handlers
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
queryClient,
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const newItems = transaction.mutations.map((m) => m.modified);
await api.createTodos(newItems);
},
onUpdate: async ({ transaction }) => {
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes,
}));
await api.updateTodos(updates);
},
onDelete: async ({ transaction }) => {
const ids = transaction.mutations.map((m) => m.key);
await api.deleteTodos(ids);
},
}),
);Transaction Object
Each persistence handler receives a transaction with a mutations array. Each mutation contains:
| Property | Description |
|---|---|
key | The item's unique key (from getKey) |
original | The item before the mutation |
modified | The item after the mutation |
changes | Partial object with only changed fields |
Refetch Control
Persistence handlers can control whether TanStack Query refetches after a mutation:
onInsert: async ({ transaction }) => {
await api.createTodos(transaction.mutations.map((m) => m.modified))
return { refetch: false }
},Returning nothing or { refetch: true } triggers an automatic refetch. Return { refetch: false } to skip it when the server response confirms the data is already correct.
Batch Mutations
When multiple items are mutated in quick succession, TanStack DB batches them into a single transaction. The transaction.mutations array contains all mutations in the batch:
onUpdate: async ({ transaction }) => {
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes,
}))
await api.batchUpdate(updates)
},Optimistic Flow
1. User action triggers collection.insert(), .update(), or .delete() 2. Instant UI update via live queries reflecting optimistic state 3. Persistence handler runs asynchronously to sync with server 4. On success the optimistic state becomes confirmed state 5. On failure the optimistic state rolls back automatically
ElectricSQL Mutations
For ElectricSQL collections, mutations sync through Electric's real-time sync engine:
const todoCollection = createCollection(
electricCollectionOptions({
id: 'todos',
schema: todoSchema,
shapeOptions: {
url: 'https://api.electric-sql.cloud/v1/shape',
params: { table: 'todos' },
},
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const response = await api.todos.create(
transaction.mutations[0].modified,
);
return { txid: response.txid };
},
}),
);Returning a txid from Electric mutations lets TanStack DB track when the server has processed the change, ensuring consistency between optimistic and confirmed state.
Setup and Configuration
Installation
Install the framework-specific package (it re-exports everything from @tanstack/db):
npm install @tanstack/react-dbFor Vue applications:
npm install @tanstack/vue-dbFor Svelte applications (requires Svelte 5 runes):
npm install @tanstack/svelte-dbInstall collection type packages based on your data source:
npm install @tanstack/query-db-collection
npm install @tanstack/electric-db-collection
npm install @tanstack/trailbase-db-collection
npm install @tanstack/rxdb-db-collectionCollection Types
| Package | Use Case |
|---|---|
@tanstack/query-db-collection | REST APIs and GraphQL via TanStack Query |
@tanstack/electric-db-collection | Real-time Postgres sync via ElectricSQL |
@tanstack/trailbase-db-collection | TrailBase backend integration |
@tanstack/rxdb-db-collection | RxDB reactive database integration |
| Built-in: LocalStorage | Persistent local data, syncs across browser tabs |
| Built-in: LocalOnly | Temporary in-memory data and UI state |
Query Collection (REST APIs)
The most common setup pairs TanStack DB with TanStack Query for REST APIs:
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,
onUpdate: async ({ transaction }) => {
const { original, modified } = transaction.mutations[0];
await fetch(`/api/todos/${original.id}`, {
method: 'PUT',
body: JSON.stringify(modified),
});
},
}),
);Key options for queryCollectionOptions:
| Option | Required | Description |
|---|---|---|
queryKey | Yes | TanStack Query cache key |
queryFn | Yes | Fetch function returning array of items |
getKey | Yes | Function returning unique identifier for each item |
onInsert | No | Handler called when items are inserted |
onUpdate | No | Handler called when items are updated |
onDelete | No | Handler called when items are deleted |
Query Collection with Full CRUD Handlers
import { createCollection } from '@tanstack/react-db';
import { queryCollectionOptions } from '@tanstack/query-db-collection';
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
queryClient,
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const newItems = transaction.mutations.map((m) => m.modified);
await api.createTodos(newItems);
},
onUpdate: async ({ transaction }) => {
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes,
}));
await api.updateTodos(updates);
},
onDelete: async ({ transaction }) => {
const ids = transaction.mutations.map((m) => m.key);
await api.deleteTodos(ids);
},
}),
);ElectricSQL Collection (Real-Time Sync)
For real-time sync from Postgres using ElectricSQL:
import { createCollection } from '@tanstack/react-db';
import { electricCollectionOptions } from '@tanstack/electric-db-collection';
const todoCollection = createCollection(
electricCollectionOptions({
id: 'todos',
schema: todoSchema,
shapeOptions: {
url: 'https://api.electric-sql.cloud/v1/shape',
params: {
table: 'todos',
},
},
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const response = await api.todos.create(
transaction.mutations[0].modified,
);
return { txid: response.txid };
},
}),
);TrailBase Collection
import { createCollection } from '@tanstack/react-db';
import { trailBaseCollectionOptions } from '@tanstack/trailbase-db-collection';
const todosCollection = createCollection(
trailBaseCollectionOptions({
id: 'todos',
recordApi: trailBaseClient.records('todos'),
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const newTodo = transaction.mutations[0].modified;
},
onUpdate: async ({ transaction }) => {
const { original, modified } = transaction.mutations[0];
},
}),
);Module-Level Declaration
Collections should be defined at module scope, not inside components:
import { createCollection } from '@tanstack/react-db';
import { queryCollectionOptions } from '@tanstack/query-db-collection';
const userCollection = createCollection(
queryCollectionOptions({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((r) => r.json()),
getKey: (user) => user.id,
}),
);
const postCollection = createCollection(
queryCollectionOptions({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then((r) => r.json()),
getKey: (post) => post.id,
}),
);
export { userCollection, postCollection };