db-core/collection-setup

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, Query initial data and scoped factories, and PowerSync query-driven sync with onLoad/onLoadSubset hooks.

Install
npx skills add 'https://github.com/TanStack/db/tree/main/packages/db/skills/db-core/collection-setup'
Download bundle ↓
main · 09776a8Scanned 2026-09-17

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md

Schema Patterns Reference

StandardSchema Integration

TanStack DB accepts any StandardSchema-compatible library via the schema option.

Supported Libraries

TInput vs TOutput

  • TInput -- type accepted by insert() and update()
  • TOutput -- type stored in collection and returned from queries

When no transforms exist, TInput === TOutput.

const schema = z.object({
  id: z.string(),
  created_at: z.string().transform((val) => new Date(val)),
})
// TInput:  { id: string, created_at: string }
// TOutput: { id: string, created_at: Date }

Union Pattern for Transforms (Required)

When a schema transforms A to B, TInput must accept both A and B. During update(), the draft contains TOutput data.

// WRONG -- update() fails because draft.created_at is Date but schema expects string
z.string().transform((val) => new Date(val))

// CORRECT
z.union([z.string(), z.date()]).transform((val) =>
  typeof val === 'string' ? new Date(val) : val,
)
// TInput: string | Date, TOutput: Date

Defaults

const schema = z.object({
  id: z.string(),
  text: z.string(),
  completed: z.boolean().default(false),
  priority: z.number().default(0),
  tags: z.array(z.string()).default([]),
  created_at: z.date().default(() => new Date()),
})
// insert({ id: "1", text: "Task" }) -- missing fields auto-filled

Computed Fields

const schema = z
  .object({
    id: z.string(),
    first_name: z.string(),
    last_name: z.string(),
  })
  .transform((data) => ({
    ...data,
    full_name: `${data.first_name} ${data.last_name}`,
  }))

Combining Defaults with Transforms

const schema = z.object({
  created_at: z
    .string()
    .default(() => new Date().toISOString())
    .transform((val) => new Date(val)),
})

Validation Examples

// Basic constraints
z.string().min(3).max(100)
z.string().email()
z.number().int().positive()
z.enum(['active', 'inactive'])
z.array(z.string()).min(1)

// Optional/nullable
z.string().optional() // can be omitted
z.string().nullable() // can be null

// Cross-field
z.object({ start: z.string(), end: z.string() }).refine(
  (d) => new Date(d.end) > new Date(d.start),
  'End must be after start',
)

// Custom
z.string().refine((v) => /^[a-zA-Z0-9_]+$/.test(v), 'Alphanumeric only')

SchemaValidationError

import { SchemaValidationError } from '@tanstack/db'

try {
  collection.insert({ id: '1', email: 'bad', age: -5 })
} catch (error) {
  if (error instanceof SchemaValidationError) {
    error.type // "insert" or "update"
    error.message // "Validation failed with 2 issues"
    error.issues // [{ path: ["email"], message: "Invalid email" }, ...]
  }
}

Scope: Schema vs Sync — Two Separate Paths

Schemas validate client mutations only (insert(), update()). Synced data from backends (Electric, PowerSync, etc.) bypasses the schema entirely.

This means for types that need transformation (e.g., timestamptz):

  • Sync path: handled by the adapter's parser (e.g., Electric's shapeOptions.parser)
  • Mutation path: handled by the Zod schema

You need BOTH configured for full type safety. See electric-adapter.md for the dual-path pattern.

Simpler date coercion (Zod-specific)

With Zod, z.coerce.date() is simpler than the z.union([z.string(), z.date()]).transform(...) pattern:

// Zod-specific: z.coerce.date() accepts string, number, or Date as input
const schema = z.object({
  created_at: z.coerce.date(),
})
// TInput: { created_at: string | number | Date }  (coerce accepts many types)
// TOutput: { created_at: Date }

This satisfies the TInput-superset-of-TOutput requirement automatically. Other StandardSchema libraries have their own coercion patterns — consult library docs.

Important

  • Validation is synchronous, runs on every mutation
  • Keep transforms simple for performance

Where TOutput Appears

  • Data stored in collection and returned from queries
  • PendingMutation.modified
  • Mutation handler transaction.mutations[].modified

Performance

Keep transforms simple -- validation runs synchronously on every mutation.

Complete Example

import { z } from 'zod'
import { createCollection, SchemaValidationError } from '@tanstack/react-db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'

const todoSchema = z.object({
  id: z.string(),
  text: z.string().min(1, 'Text is required'),
  completed: z.boolean().default(false),
  priority: z.enum(['low', 'medium', 'high']).default('medium'),
  created_at: z
    .union([z.string(), z.date()])
    .transform((val) => (typeof val === 'string' ? new Date(val) : val))
    .default(() => new Date()),
})

const todosCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: async () => fetch('/api/todos').then((r) => r.json()),
    queryClient,
    getKey: (item) => item.id,
    schema: todoSchema,
    onInsert: async ({ transaction }) => {
      const todo = transaction.mutations[0].modified
      await api.todos.create({
        ...todo,
        created_at: todo.created_at.toISOString(),
      })
    },
  }),
)

// Defaults and transforms applied
todosCollection.insert({ id: '1', text: 'Buy groceries' })
// => { id: "1", text: "Buy groceries", completed: false, priority: "medium", created_at: Date }

// Update works -- draft contains TOutput, schema accepts via union
todosCollection.update('1', (draft) => {
  draft.completed = true
})

// Error handling
try {
  todosCollection.insert({ id: '2', text: '' })
} catch (e) {
  if (e instanceof SchemaValidationError) {
    console.log(e.issues) // [{ path: ["text"], message: "Text is required" }]
  }
}
Referenced from SKILL.md