TanStack logo

Skill

db-core/collection-setup

set up typed collections in TanStack DB

Covers Database Data Modeling TanStack

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

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

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

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.

© 2026 YourAI.tools. Every skill from an identity-verified publisher.

Independent catalog. Not affiliated with, endorsed by, or sponsored by Anthropic or any listed publisher. All trademarks belong to their respective owners.