@tanstack/db

A reactive client store for building super fast apps on sync

db-core/collection-setup

sub-skill
428 linesSource

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, 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

ts
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

BackendAdapterPackage
REST API / TanStack QueryqueryCollectionOptions@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 localStoragelocalStorageCollectionOptions@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

ts
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
})
ModeBest forData size
eagerMostly-static datasets<10k rows
on-demandSearch, catalogs, large tables>50k rows
progressiveCollaborative apps needing instant first paintAny

Core Patterns

Local-only collection for prototyping

ts
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

ts
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>.

ts
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:

ts
queryCollectionOptions({
  queryFn: async () => {
    const res = await fetch('/api/todos?status=active')
    return res.json() // returns [] when no active todos — deletes everything
  },
})

Correct:

ts
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:

ts
const todoCollection = createCollection(
  localOnlyCollectionOptions({
    getKey: (item) => item.id,
  }),
)
// Manually fetching and inserting...

Correct:

ts
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:

ts
// 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:

ts
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:

ts
queryCollectionOptions({
  queryFn: async () => {
    const newItems = await fetch('/api/todos?since=' + lastSync)
    return newItems.json() // only new items — everything else deleted
  },
})

Correct:

ts
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:

ts
const schema = z.object({
  email: z.string().refine(async (val) => {
    const exists = await checkEmail(val)
    return !exists
  }),
})

Correct:

ts
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:

ts
createCollection(
  queryCollectionOptions({
    getKey: (item) => item.metadata.id, // undefined if metadata missing
  }),
)

Correct:

ts
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:

ts
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:

ts
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:

ts
createCollection<Todo>(queryCollectionOptions({ schema: todoSchema, ... }))

Correct:

ts
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:

ts
todoCollection.utils.writeInsert(newItem)
// Next queryFn execution replaces all data, losing the direct write

Correct:

ts
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

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.