db-core/collection-setup
sub-skillCreating typed collections with createCollection. Adapter selection: queryCollectionOptions (REST/TanStack Query), electricCollectionOptions (ElectricSQL real-time sync), powerSyncCollectionOptions (PowerSync SQLite), rxdbCollectionOptions (RxDB), trailbaseCollectionOptions (TrailBase), localOnlyCollectionOptions, localStorageCollectionOptions. CollectionConfig options: getKey, schema, sync, gcTime, autoIndex, syncMode (eager/on-demand/ progressive). StandardSchema validation with Zod/Valibot/ArkType. Collection lifecycle (idle/loading/ready/error). Adapter-specific sync patterns including Electric txid tracking and Query direct writes.
This skill builds on db-core. Read it first for the overall mental model.
Collection Setup & Schema
Setup
import { createCollection } from '@tanstack/react-db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'
import { QueryClient } from '@tanstack/query-core'
import { z } from 'zod'
const queryClient = new QueryClient()
const todoSchema = z.object({
id: z.number(),
text: z.string(),
completed: z.boolean().default(false),
created_at: z
.union([z.string(), z.date()])
.transform((val) => (typeof val === 'string' ? new Date(val) : val)),
})
const todoCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
return res.json()
},
queryClient,
getKey: (item) => item.id,
schema: todoSchema,
onInsert: async ({ transaction }) => {
await api.todos.create(transaction.mutations[0].modified)
await todoCollection.utils.refetch()
},
onUpdate: async ({ transaction }) => {
const mut = transaction.mutations[0]
await api.todos.update(mut.key, mut.changes)
await todoCollection.utils.refetch()
},
onDelete: async ({ transaction }) => {
await api.todos.delete(transaction.mutations[0].key)
await todoCollection.utils.refetch()
},
}),
)
Choosing an Adapter
| Backend | Adapter | Package |
|---|---|---|
| REST API / TanStack Query | queryCollectionOptions | @tanstack/query-db-collection |
| ElectricSQL (real-time Postgres) | electricCollectionOptions | @tanstack/electric-db-collection |
| PowerSync (SQLite offline) | powerSyncCollectionOptions | @tanstack/powersync-db-collection |
| RxDB (reactive database) | rxdbCollectionOptions | @tanstack/rxdb-db-collection |
| TrailBase (event streaming) | trailbaseCollectionOptions | @tanstack/trailbase-db-collection |
| No backend (UI state) | localOnlyCollectionOptions | @tanstack/db |
| Browser localStorage | localStorageCollectionOptions | @tanstack/db |
If the user specifies a backend (e.g. Electric, PowerSync), use that adapter directly. Only use localOnlyCollectionOptions when there is no backend yet — the collection API is uniform, so swapping to a real adapter later only changes the options creator.
Sync Modes
queryCollectionOptions({
syncMode: 'eager', // default — loads all data upfront
// syncMode: "on-demand", // loads only what live queries request
// syncMode: "progressive", // (Electric only) query subset first, full sync in background
})
| Mode | Best for | Data size |
|---|---|---|
| eager | Mostly-static datasets | <10k rows |
| on-demand | Search, catalogs, large tables | >50k rows |
| progressive | Collaborative apps needing instant first paint | Any |
Core Patterns
Local-only collection for prototyping
import {
createCollection,
localOnlyCollectionOptions,
} from '@tanstack/react-db'
const todoCollection = createCollection(
localOnlyCollectionOptions({
getKey: (item) => item.id,
initialData: [{ id: 1, text: 'Learn TanStack DB', completed: false }],
}),
)
Schema with type transformations
const schema = z.object({
id: z.number(),
title: z.string(),
due_date: z
.union([z.string(), z.date()])
.transform((val) => (typeof val === 'string' ? new Date(val) : val)),
priority: z.number().default(0),
})
Use z.union([z.string(), z.date()]) for transformed fields — this ensures TInput is a superset of TOutput so that update() works correctly with the draft proxy.
ElectricSQL with txid tracking
Always use a schema with Electric — without one, the collection types as Record<string, unknown>.
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
import { z } from 'zod'
const todoSchema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(),
created_at: z.coerce.date(),
})
const todoCollection = createCollection(
electricCollectionOptions({
schema: todoSchema,
shapeOptions: { url: '/api/electric/todos' },
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const res = await api.todos.create(transaction.mutations[0].modified)
return { txid: res.txid }
},
}),
)
The returned txid tells the collection to hold optimistic state until Electric streams back that transaction. See the Electric adapter reference for the full dual-path pattern (schema + parser).
Common Mistakes
CRITICAL queryFn returning empty array deletes all data
Wrong:
queryCollectionOptions({
queryFn: async () => {
const res = await fetch('/api/todos?status=active')
return res.json() // returns [] when no active todos — deletes everything
},
})
Correct:
queryCollectionOptions({
queryFn: async () => {
const res = await fetch('/api/todos') // fetch complete state
return res.json()
},
// Use on-demand mode + live query where() for filtering
syncMode: 'on-demand',
})
queryFn result is treated as complete server state. Returning [] means "server has no items", deleting all existing collection data.
Source: docs/collections/query-collection.md
CRITICAL Not using the correct adapter for your backend
Wrong:
const todoCollection = createCollection(
localOnlyCollectionOptions({
getKey: (item) => item.id,
}),
)
// Manually fetching and inserting...
Correct:
const todoCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: async () => fetch('/api/todos').then((r) => r.json()),
queryClient,
getKey: (item) => item.id,
}),
)
Each backend has a dedicated adapter that handles sync, mutation handlers, and utilities. Using localOnlyCollectionOptions or bare createCollection for a real backend bypasses all of this.
Source: docs/overview.md
CRITICAL Electric txid queried outside mutation transaction
Wrong:
// Backend handler
app.post('/api/todos', async (req, res) => {
const txid = await generateTxId(sql) // WRONG: separate transaction
await sql`INSERT INTO todos ${sql(req.body)}`
res.json({ txid })
})
Correct:
app.post('/api/todos', async (req, res) => {
let txid
await sql.begin(async (tx) => {
txid = await generateTxId(tx) // CORRECT: same transaction
await tx`INSERT INTO todos ${tx(req.body)}`
})
res.json({ txid })
})
pg_current_xact_id() must be queried inside the same SQL transaction as the mutation. Otherwise the txid doesn't match and awaitTxId stalls forever.
Source: docs/collections/electric-collection.md
CRITICAL queryFn returning partial data without merging
Wrong:
queryCollectionOptions({
queryFn: async () => {
const newItems = await fetch('/api/todos?since=' + lastSync)
return newItems.json() // only new items — everything else deleted
},
})
Correct:
queryCollectionOptions({
queryFn: async (ctx) => {
const existing = ctx.queryClient.getQueryData(['todos']) || []
const newItems = await fetch('/api/todos?since=' + lastSync).then((r) =>
r.json(),
)
return [...existing, ...newItems]
},
})
queryFn result replaces all collection data. For incremental fetches, merge with existing data.
Source: docs/collections/query-collection.md
HIGH Using async schema validation
Wrong:
const schema = z.object({
email: z.string().refine(async (val) => {
const exists = await checkEmail(val)
return !exists
}),
})
Correct:
const schema = z.object({
email: z.string().email(),
})
// Do async validation in the mutation handler instead
Schema validation must be synchronous. Async validation throws SchemaMustBeSynchronousError at mutation time.
Source: packages/db/src/collection/mutations.ts:101
HIGH getKey returning undefined for some items
Wrong:
createCollection(
queryCollectionOptions({
getKey: (item) => item.metadata.id, // undefined if metadata missing
}),
)
Correct:
createCollection(
queryCollectionOptions({
getKey: (item) => item.id, // always present
}),
)
getKey must return a defined value for every item. Throws UndefinedKeyError otherwise.
Source: packages/db/src/collection/mutations.ts:148
HIGH TInput not a superset of TOutput with schema transforms
Wrong:
const schema = z.object({
created_at: z.string().transform((val) => new Date(val)),
})
// update() fails — draft.created_at is Date but schema only accepts string
Correct:
const schema = z.object({
created_at: z
.union([z.string(), z.date()])
.transform((val) => (typeof val === 'string' ? new Date(val) : val)),
})
When a schema transforms types, TInput must accept both the pre-transform and post-transform types for update() to work with the draft proxy.
Source: docs/guides/schemas.md
HIGH React Native missing crypto.randomUUID polyfill
TanStack DB uses crypto.randomUUID() internally. React Native doesn't provide this. Install react-native-random-uuid and import it at your app entry point.
Source: docs/overview.md
MEDIUM Providing both explicit type parameter and schema
Wrong:
createCollection<Todo>(queryCollectionOptions({ schema: todoSchema, ... }))
Correct:
createCollection(queryCollectionOptions({ schema: todoSchema, ... }))
When a schema is provided, the collection infers types from it. An explicit generic creates conflicting type constraints.
Source: docs/overview.md
MEDIUM Direct writes overridden by next query sync
Wrong:
todoCollection.utils.writeInsert(newItem)
// Next queryFn execution replaces all data, losing the direct write
Correct:
todoCollection.utils.writeInsert(newItem)
// Use staleTime to prevent immediate refetch
// Or return { refetch: false } from mutation handlers
Direct writes update the collection immediately, but the next queryFn returns complete server state which overwrites them.
Source: docs/collections/query-collection.md
References
- TanStack Query adapter
- ElectricSQL adapter
- PowerSync adapter
- RxDB adapter
- TrailBase adapter
- Local adapters (local-only, localStorage)
- Schema validation patterns
See also: db-core/mutations-optimistic/SKILL.md — mutation handlers configured here execute during mutations.
See also: db-core/custom-adapter/SKILL.md — for building your own adapter.