
Skill
db-core/collection-setup
set up typed collections in TanStack DB
Description
Creating 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 (default off), defaultIndexType, syncMode (eager/on-demand, plus progressive for Electric). StandardSchema validation with Zod/Valibot/ArkType. Collection lifecycle (idle/loading/ready/error). Adapter-specific sync patterns including Electric txid tracking, Query direct writes, and PowerSync query-driven sync with onLoad/onLoadSubset hooks.
SKILL.md
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 (ctx) => {
const res = await fetch('/api/todos', { signal: ctx.signal })
return res.json()
},
queryClient,
getKey: (item) => item.id,
schema: todoSchema,
onInsert: async ({ transaction }) => {
await api.todos.create(transaction.mutations[0].modified)
},
onUpdate: async ({ transaction }) => {
const mut = transaction.mutations[0]
await api.todos.update(mut.key, mut.changes)
},
onDelete: async ({ transaction }) => {
await api.todos.delete(transaction.mutations[0].key)
},
}),
)
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 (Electric only) | Any |
Calling collection.preload() on an on-demand collection is a no-op. Create
the live query for the required subset and call liveQuery.preload() instead.
For Query Collection request cancellation, cleanup boundaries, and shared
QueryClient behavior, read
the Query adapter reference.
Indexing
Indexing is opt-in. The autoIndex option defaults to "off". To enable automatic indexing, set autoIndex: "eager" and provide a defaultIndexType:
import { BasicIndex } from '@tanstack/db'
createCollection(
queryCollectionOptions({
autoIndex: 'eager',
defaultIndexType: BasicIndex,
// ...
}),
)
Without defaultIndexType, setting autoIndex: "eager" throws a CollectionConfigurationError. You can also create indexes manually with collection.createIndex() and remove them with collection.removeIndex().
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 times out (default 5 seconds).
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.
More skills from the db repository
View all 13 skillsangular-db
integrate TanStack DB with Angular
Jul 16AngularDatabaseTypeScriptdb-core
manage database collections with TanStack DB
Jul 17Data ModelingDatabaseTanStackdb-core/custom-adapter
build custom TanStack collection adapters
Jul 16DatabaseEngineeringTanStackdb-core/live-queries
build live queries with TanStack DB
Jul 16DatabaseSQLTanStackdb-core/mutations-optimistic
manage optimistic mutations in TanStack DB
Jul 26DatabaseFrontendTanStackdb-core/persistence
implement SQLite-backed persistence for TanStack DB
Jul 17DatabasePersistenceSQLiteTanStack
More from TanStack
View publisheraggregation
perform data aggregation in TanStack Table
table
Jul 26Data AnalysisFrontendTanStackapi-not-found
diagnose TanStack Table API errors
table
Jul 26DebuggingFrontendTanStackcell-selection
select rectangular cell ranges in tables
table
Jul 26Data AnalysisTanStackUI Componentsclient-vs-server
manage TanStack Table data pipelines
table
Jul 26Data PipelineFrontendPerformanceTanStackcolumn-faceting
build faceted filter UIs
table
Jul 26Data VisualizationFrontendTanStackcolumn-filtering
implement column filtering in TanStack Table
table
Jul 26Data AnalysisFrontendTanStack