TanStack
Guides

Migrating to the 1.0 RC

This guide covers breaking changes when upgrading from @tanstack/db@0.9.2 to 1.0.0-rc.1, including the accompanying Collection adapter releases. Every "before" example uses the APIs available alongside DB 0.9.2. Optional new features are listed separately at the end. For SSR setup, see the SSR and Hydration guide.

Update your packages

Update the pre-1.0 TanStack DB packages your app uses together. Once the RC is published, install them from npm's rc tag. For example:

shell
npm install @tanstack/react-db@rc @tanstack/electric-db-collection@rc

The first candidate is 1.0.0-rc.1. @tanstack/query-db-collection and @tanstack/offline-transactions were both accidentally released as 1.0 before the RC. Keep them on 1.x and update to their latest 1.x releases. These releases use the app's @tanstack/db as a peer dependency, so the RC and stable packages share one DB instance. Both accompanying minor releases require @tanstack/db@^1.0.0-rc.0; upgrade DB at the same time. Query Collection uses the RC's status and health API, and Offline Transactions uses the RC's restoration settlement API. Apps staying on an earlier DB release must keep their compatible adapter versions.

Replace $synced with $hasPendingWrites

The $synced virtual row property has been removed. Its replacement has the opposite boolean meaning:

BeforeAfter
row.$synced!row.$hasPendingWrites
!row.$syncedrow.$hasPendingWrites
eq(row.$synced, true)eq(row.$hasPendingWrites, false)
eq(row.$synced, false)eq(row.$hasPendingWrites, true)

$hasPendingWrites reports whether a pending local optimistic mutation affects the row. A false value does not, by itself, prove that the server confirmed the write. See Virtual properties for the other row metadata fields.

If you create virtual rows yourself, include $hasPendingWrites: hasVirtualProps(row) now requires it.

Keep virtual field names for row metadata

The fields $hasPendingWrites, $origin, $key, and $collectionId are reserved on published Collection rows. A selected field with one of those names cannot author conflicting metadata at a live-query row root. Structured .select() calls that return or spread a source row retain its metadata, including a joined source. With several source spreads, the last supplies the metadata. Other projections receive the live-query Collection's metadata, regardless of selected virtual field names.

A functional .fn.select() preserves source metadata only when it returns a source row directly, such as ({ user }) => user. A new object, including ({ user }) => ({ ...user }), receives the live-query Collection's metadata. Identical field values do not establish which source authored that object. These rules also apply to Collection-valued include rows. Inline toArray() and materialize() results keep their selected shape. Rename application data fields that collide with these names before selecting them as row roots.

Wait for transaction settlement

Replace transaction.isPersisted.promise with transaction.when('settled'):

ts
const todoId = "todo-1"
const transaction = todoCollection.update(todoId, (draft) => {
  draft.completed = true
})

// Before: await transaction.isPersisted.promise
await transaction.when('settled')

when('settled') resolves with the transaction after its mutation handler completes successfully and rejects when the transaction fails or rolls back. Transaction settlement confirms a backend write only if the handler waits for that confirmation before returning. See the Mutation Lifecycle for the full boundary.

Await synchronization inside mutation handlers

The pre-1.0 Collection adapters no longer use values returned by onInsert, onUpdate, or onDelete to coordinate synchronization. Their handlers now return Promise<void>. Query and Electric ignore legacy return values at runtime. A discarded value does not turn a successful server write into a failed transaction, and it does not coordinate synchronization. Await the work that must finish before the transaction settles:

ts
onUpdate: async ({ transaction }) => {
  await api.todos.update(transaction.mutations[0].changes)
  // Await any required backend confirmation or read-back here.
}

For Electric Collection, replace a returned { txid } matching strategy with collection.utils.awaitTxId(txid) inside the handler:

ts
// Before
onUpdate: async ({ transaction }) => {
  const { txid } = await api.todos.update(transaction.mutations[0].changes)
  return { txid, timeout: 10_000 }
}

// After
onUpdate: async ({ transaction, collection }) => {
  const { txid } = await api.todos.update(transaction.mutations[0].changes)
  await collection.utils.awaitTxId(txid, 10_000)
}

For several transaction IDs, await all of them with Promise.all. The Electric Collection guide also covers custom matching when your API cannot return transaction IDs.

Query Collection handlers no longer refetch

The accompanying Query Collection 1.x minor release removes automatic refetch from onInsert, onUpdate, and onDelete. Refresh explicitly inside each handler:

ts
// Before: returning triggered an automatic refetch.
onUpdate: async ({ transaction }) => {
  await api.todos.update(transaction.mutations[0].changes)
}

// After
onUpdate: async ({ transaction, collection }) => {
  await api.todos.update(transaction.mutations[0].changes)
  await collection.utils.refetch()
}

Alternatively, await a writeInsert, writeUpdate, writeUpsert, or writeDelete call with the confirmed server result. These utilities do not fetch. If the handler neither refreshes nor writes confirmed data, its optimistic changes revert to the previous synced rows when the transaction settles, even if the server accepted the mutation.

Handlers now return Promise<void>. Remove { refetch: false }; the types reject it, and runtime code ignores it. A handler that already explicitly refetches or writes its server response needs only to remove that return value. See Controlling Refetch Behavior.

For TypeScript code that uses the handler type aliases directly, InsertMutationFn, UpdateMutationFn, and DeleteMutationFn no longer take a return-value type parameter. BaseCollectionConfig no longer takes its old TReturn type parameter. The Electric MatchingStrategy type was removed with the returned matching strategy.

Import the includes and grouping operators

These operators now need an explicit import so apps bundle only the operators they use.

Wrap nested selections in include()

A child query returned directly from .select() now throws IncludeOperatorRequiredError. Wrap it in include() to keep a live child Collection:

ts
import { eq, include } from '@tanstack/db'

// Before
q.from({ project: projectsCollection }).select(({ project }) => ({
  issues: q.from({ issue: issuesCollection })
    .where(({ issue }) => eq(issue.projectId, project.id)),
}))

// After
q.from({ project: projectsCollection }).select(({ project }) => ({
  issues: include(
    q.from({ issue: issuesCollection })
      .where(({ issue }) => eq(issue.projectId, project.id)),
  ),
}))

The toArray() and materialize() wrappers opt in; they need no additional include(). Apply the wrapper at every nested selection. See Includes.

Wrap grouping keys when there is no aggregate

Aggregates such as count() already include the grouping compiler. A query that groups without an aggregate must use groupKeys(), or it throws GroupingOperatorRequiredError:

ts
import { groupKeys } from '@tanstack/db'

// Before: .groupBy(({ todo }) => todo.status)
q.from({ todo: todoCollection })
  .groupBy(groupKeys(({ todo }) => todo.status))
  .select(({ todo }) => ({ status: todo.status }))

If you construct query IR directly, pass groupingCompiler as the third argument to IR.Aggregate, and includesRuntime as the fifth argument to IR.IncludesSubquery, before parentFilters. Both values are exported from @tanstack/db. Queries built through the query builder use the operators above.

Update React live-query hooks

Dependency arrays were removed from useLiveQuery, useLiveSuspenseQuery, and useLiveInfiniteQuery. For structured queries, omit the array: the hooks derive identity from the query, including captured values used in structured expressions.

tsx
// Before
useLiveQuery(
  (q) => q.from({ todos: todoCollection })
    .where(({ todos }) => eq(todos.status, status)),
  // @ts-expect-error Dependency arrays are removed in the RC.
  [status],
)

// After
useLiveQuery({
  query: (q) => q.from({ todos: todoCollection })
    .where(({ todos }) => eq(todos.status, status)),
})

Opaque functional query clauses, such as .fn.where, need an explicit, serializable queryKey that includes their captured values:

tsx
useLiveQuery({
  queryKey: [todoCollection.id, 'status', status],
  query: (q) => q.from({ todos: todoCollection })
    .fn.where(({ todos }) => todos.status === status),
})

For config-object queries, getKey and runtime schema options bind when the query identity changes. Independent DbClient callers that reuse a cached query identity must use the same getKey and schema references. Use distinct queryKey values for different row behavior. Allocating an inline callback or schema object during a render does not rebuild the Collection. Change the query or a nonempty queryKey when you need to apply different options.

An unhashable query without a nonempty queryKey now throws UnhashableQueryIRError; queryKey: [] counts as absent. Put the key in the config object for useLiveQuery or useLiveSuspenseQuery; pass it in the second argument's config for useLiveInfiniteQuery(query, { pageSize, queryKey }). See Query Identity for details.

Check identity in other frameworks and keep sources stable

The nonempty queryKey requirement also applies to Preact and to any hook backed by a DbClient, including Svelte. It covers .fn.where, .fn.select, and .fn.having. Without a DbClient, Vue, Solid, Angular, and Svelte can recompile from reactive reads and do not require a key for opaque clauses.

For a mounted hook that compares derived identity, a source Collection ID must keep naming the same Collection object in that client scope. Replacing the object while reusing its ID now throws SourceCollectionIdReuseError. Keep source Collections stable across renders. To reuse an ID in that binding, unmount it, clean up its source and client scope, then mount the replacement. Separate bindings and independent client scopes can use the same ID.

An explicit queryKey makes identity your responsibility: include a primitive scope token in it and change the key when replacing the source. Do not put a Collection object in the key. See Query Identity.

Read Solid query data through the accessor

Solid useLiveQuery no longer exposes the deprecated .data alias. Call the returned accessor wherever you read the result:

tsx
import { For } from "solid-js"
import { useLiveQuery } from "@tanstack/solid-db"

const todosQuery = useLiveQuery((q) => q.from({ todos: todoCollection }))

// Before: <For each={todosQuery.data}>...</For>
const TodoList = () => (
  <For each={todosQuery()}>{(todo) => <li>{todo.text}</li>}</For>
)

Status fields remain reactive properties, such as todosQuery.isLoading. See the Solid adapter guide.

Remove the obsolete alias error import

DuplicateAliasInSubqueryError has been removed. Nested queries may reuse an ancestor's alias; aliases must still be unique within one lexical scope. Remove imports and catches for this obsolete error.

Separate loading failures from sync problems

Collection status now describes whether data can be shown. After readiness, a network or backend failure keeps the rows and status: 'ready'; the problem appears in health. Local storage failures, a throwing sync function, and internal invariant failures use the new terminal crashed status.

Live-query hooks no longer expose isError. Replace it with isLoadingError for a failed first load, isCrashed for a crash, and isSyncError for sync problems while ready rows remain visible.

Update status switches, hook result destructuring, error screens, and retry buttons:

BeforeAfter
Hook isErrorisLoadingError for a failed first load; isCrashed for a crash
status === 'error' for every failureerror only for a failed first load; crashed for local failure; health for sync problems after readiness
Hide rows on any sync errorKeep ready rows visible and use isSyncError for a stale-data banner
Query Collection utils.lastError, utils.isError, utils.errorCountRead collection.health or the hook's health tree
Query Collection utils.clearError()collection.reload() for a failed first load; utils.refetch({ throwOnError: true }) to retry a fetch and observe rejection
Infinite hook error in React, Preact, Vue, or SvelteRead the root node's health.error (through a ref in Vue)

isSyncError is true when the query is ready and any node in its health tree has a problem. Each source alias has a node: failureReason means the adapter is still trying, and error means it stopped. On the query's own node, error can also mean a request for more data failed. findProblems(health) from @tanstack/db finds the nodes with problems. A problem's cause preserves the reported value; its errors array lists failures still open, oldest first. Do not treat that array's length as a count of all failed fetches.

fetchNextPage() still resolves when a page load fails. Inspect health afterward; catching its promise does not detect a failed page load. useLiveSuspenseQuery throws for a failed first load or a crash. Sync problems after readiness stay in health and do not reach the error boundary. See Loading and Sync Errors.

Effects also keep running through sync problems after their initial load. Move handling of those problems from onSourceError to onHealthChange. onSourceError still ends the Effect on a failed first load, crash, or failed load that the Effect needs.

Retry with reload() and update cleanup calls

For a failed load or a stopped sync adapter, replace cleanup-and-preload retry code with reload():

ts
// Before
await todosQuery.cleanup()
await todoCollection.cleanup()
await todosQuery.preload()

// After
await todosQuery.reload()

A live query reloads errored sources recursively, then retries its own error when needed. Existing rows, status, and error stay visible while it reloads; check health.reloading for progress. A failed reload rejects. A local-only Collection's reload preserves its rows and does not reset them to initialData.

cleanup() now rejects with CollectionHasSubscribersError if an active live query or Effect reads the Collection. Unmount or dispose those readers first, or use await collection.cleanup({ dependents: true }) to end them. This ends the dependents; mount or create them again after restarting. Inspect collection.subscribers to find them. Raw subscribeChanges() subscriptions do not block cleanup and are not ended by it. New subscriptions and deferred acquisition resumption throw while cleanup is in progress; wait for cleanup to settle before restarting.

A crashed Collection cannot reload. Recover with await collection.cleanup({ dependents: true }), then start it again, for example with await collection.preload(), and recreate its readers. Cleanup discards its rows. For a closed IndexedDB connection, create a new connection and affected Collections. See When a Collection crashes.

Prepare SSR data explicitly

An ordinary server hook can render empty/loading state without preloading. Rendering rows or readiness now requires a matching preloadLiveQuery() or explicit preparation of its source Collections. Another query incidentally warming the same source is insufficient and now throws with preparation guidance. Streaming Suspense prepares its own result.

Share a config object between the loader and the hook:

tsx
import { eq, type InitialQueryBuilder } from '@tanstack/db'
import { useLiveQuery } from '@tanstack/react-db'

const openTodos = {
  query: (q: InitialQueryBuilder) => q.from({ todo: todoCollection })
    .where(({ todo }) => eq(todo.status, 'open')),
}

// In the server loader:
await dbClient.preloadLiveQuery(openTodos)

// In the component:
const { data } = useLiveQuery(openTodos)

Keep the input form consistent: useLiveQuery(openTodos.query) currently has a different identity from useLiveQuery(openTodos). If using an explicit queryKey, use the same key and query configuration in the preload and hook. For infinite queries, the identity also includes the initial limit and offset: preload the first window with limit(pageSize + 1).offset(0) to include its one-row peek. See Prepare every server snapshot.

Create manual indexes when initializing the request or browser client, before preloading or rendering queries that depend on them. Installing an index inside the sync callback can be too late for query compilation. See Indexes before query compilation.

Svelte roots initialized with hydrate() must mark their provider:

svelte
<DbProvider {client} hydrating>
  <Todos />
</DbProvider>

Nested providers inherit hydrating, and each provider clears it after mount. This also applies to hooks that receive client explicitly. Omit it for a root created with mount(). It preserves each reader's server snapshot through hydration, even when browser data has already changed. See Svelte SSR.

Treat hydrated rows as provisional seeds

DbClient hydration and initialData supply provisional rows. A source's first insert for a seeded key replaces that seed, including its row metadata; an insert without metadata clears the seed's metadata. An update merges over the seed unless the adapter uses rowUpdateMode: 'full'.

A later hydration seed cannot overwrite source-written row data or metadata, or restore a key the source deleted or truncated. Row data and row metadata are tracked separately, so a seed may still supply metadata when the source wrote only the row data. For row data or metadata the source has not written, the latest seed wins. Persisted cache rows also remain provisional to later DbClient seeds, including after a storage reload from another tab. See Initial Data.

Await Query Collection direct writes

writeInsert, writeUpdate, writeDelete, writeUpsert, and writeBatch now return promises whose validation errors reject. Catch errors from an awaited write, including DuplicateKeySyncError when writeInsert uses an existing key in a persisted Collection:

ts
try {
  await todosCollection.utils.writeInsert(todo)
} catch (error) {
  // Handle the rejected write.
}

Direct writes update Collection and Query cache rows where the write can be represented. Call await todosCollection.utils.refetch() when you need current server rows. Mutation handlers require explicit synchronization as described above. Use TanStack Query Core 5.90.15 or later for post-write refetch replacement and error recovery. See Direct Writes.

Update custom sync adapters

  • Remove the immediate argument from begin({ immediate: true }) in sync callbacks. Call begin() instead. Sync writes made while a mutation persists now queue behind it; code must not rely on them appearing immediately.
  • Replace markError(error) with fail(error) when the adapter stops trying. Before the run's first markReady(), this fails loading; afterward it keeps ready rows and records health.error. Retry a failed run with reload().
  • While retrying the first load, call neither fail nor reportError. After the run's first markReady(), use reportError(error) while retrying, and reportHealthy() on recovery. Both throw before the run is ready or after fail(). Use reportHealthy(error) to clear just one reported failure if other independent failures remain. Stop the run's work after fail(); later writes and health reports are invalid. Repeated markReady() and fail() calls in the same run do nothing.
  • Track readiness inside each sync invocation. During a reload, collection.status still describes the previous run and cannot tell you whether the new run has reached its first ready.
  • When keeping rows during reload, update existing keys and explicitly delete removed rows. Inserting an existing key with a changed value throws DuplicateKeySyncError. A partial on-demand loadSubset result must never claim to be a full snapshot. Use begin({ snapshot: true }) or truncate() only for a complete authoritative replacement. Honor the load's signal: aborted loads no longer hold readiness, and core ignores their later promise results.
  • Treat a synchronous loadSubset throw like a rejected load. Core keeps its logical demand and calls unloadSubset when that demand is released. Ensure unloadSubset can clean up partially initialized or failed loads.
  • Sync metadata reads now include the source's own committed writes that an optimistic transaction still holds, followed by its open transactions. This applies to metadata.row.get, metadata.collection.get, and metadata.collection.list. Do not treat these reads as proof that the writes are publicly visible; commit receipts still establish application.
  • With rowUpdateMode: 'full', supply a complete replacement row. Persistence now replaces the stored row too, so fields omitted from an update no longer return after a restart. Use partial for updates that should merge fields.

See For sync adapter authors for an example and the full reload contract.

Update persistence integrations and browser assets

  • Custom SQLite PersistenceAdapter implementations must implement loadResumeSnapshot. A custom sync wrapper must forward metadata.persistence unchanged. When metadata is present, its persistence field is required and is null when there is no persistence capability.
  • Lower-level SQLite adapter data operations now take an explicit PersistedStorageTarget, such as eagerStorageTarget(collectionId) or a claim-bound managedStorageTarget(storageCollectionId, claimId), instead of a bare Collection ID. Ordinary Collection configuration does not need a target.
  • Keep Electron persistence code in the main and renderer processes on matching package versions; mixed IPC protocol versions fail closed.
  • openBrowserWASQLiteOPFSDatabase now rejects if opening takes more than 30 seconds. Set timeoutMs: 0 only if the previous unlimited wait is required.
  • If a custom React Native op-sqlite wrapper returns ambiguous bare arrays, set arrayResultMode to rows or statement-results. These arrays now reject without an explicit mode.
  • Local persisted Collections without a sync option reserve the utility names acceptMutations, getLeadershipState, and forceReloadSubset. Rename custom utilities with those names. persistedCollectionOptions now throws InvalidPersistedCollectionConfigError for them, even when inherited or set to undefined. Collections with a sync option keep their adapter utilities.

Browser SQLite now ships wa-sqlite.wasm separately from its OPFS worker. Deploy both emitted assets. Vite, webpack 5, and Next.js include them automatically; ordinary app setup needs no asset copying or WASM URL option.

In Vite development, pre-bundled installs reached only through another library or an npm alias may fail to resolve the asset package. Multiple installed versions can select a mismatched worker. If opening rejects with PersistenceUnavailableError in these cases, exclude the package from pre-bundling:

ts
// vite.config.ts
export default defineConfig({
  optimizeDeps: {
    exclude: ['@tanstack/browser-db-sqlite-persistence'],
  },
})

For an npm alias, list the alias used by the importing code instead. The same exclusion is needed if the database opens inside a worker or the page's Vite client has no script src. See the Browser SQLite runtime notes.

Review query windows, joins, and aggregates

  • An ordered findOne keeps its compiled limit of one, or zero after .limit(0).findOne(). setWindow may move its offset but cannot change its limit. An unordered findOne stays at offset zero. Move to an ordered, limited multi-row query if the result needs a larger or movable window. setWindow controls only the outer query's own window, not a window inside a subquery or include.
  • Joined row keys now use JSON arrays of source keys, with null for a missing side. Code that constructs a key to call collection.get() must use the new format. Reading the published row's $key avoids reconstructing it, especially for non-finite numeric keys.
  • Aggregate validation also applies to queries without groupBy, source fields inside expressions that mix aggregate and non-aggregate values, and having. These cases could pass validation in 0.9.2. Aggregate each source field or include it in groupBy to avoid NonAggregateExpressionNotInGroupByError. Nested includes inside a grouped or aggregate select now throw as well.
  • Clean up the owning live query rather than calling cleanup() on one of its included child Collections. The child cleanup now rejects because several parent rows can share that Collection.

Use string or number keys throughout

sync.write now enforces the same key restriction as optimistic writes: getKey must return a string or number. An undefined key throws UndefinedKeyError; other invalid keys throw InvalidKeyError. Convert boolean, bigint, nullable, or compound identifiers to a stable string or number in your adapter's getKey.

Single-field grouped results now encode null, undefined, boolean, and bigint group values as string keys. String and number group values keep their keys, except strings beginning with the reserved \u0000tanstack-db: prefix, which are encoded too. Read the published row's $key when retrieving a group; do not reconstruct a key from its grouping value. A custom live-query getKey still overrides the default. See groupBy and Aggregations.

SortedMap is no longer exported from @tanstack/db. Remove imports of this internal ordered map; use public Collection reads or an application-owned map.

Check stricter inputs and adapter requirements

  • A custom index compareFn or collation compare must return a number other than NaN. Invalid comparator results now throw instead of silently misordering an index.
  • Browser development builds now detect two loaded copies of @tanstack/db and throw DuplicateDbInstanceError. Deduplicate the package if the check appears after upgrading.
  • Pass fresh persisted or LocalStorage sync options to each direct createCollection call. Reusing mutable options across Collections now rejects. Reusable DbClient descriptors are unaffected.
  • Collection.update keys must match the Collection's declared key type. Narrow possibly undefined keys, and use the branded key type if the Collection declares one. Reusable Ref<T> helpers that require virtual row metadata should set their third type parameter to true.
  • Upgrade to PowerSync 2 before updating @tanstack/powersync-db-collection, and to RxDB 17 before updating @tanstack/rxdb-db-collection.
  • Vue live-query data.value is a snapshot. Read data.value again to get later rows instead of keeping a previously read array. If a changed query fails to compile, the hook no longer displays the previous query's rows.
  • A default auto-committed offline transaction accepts only its first mutate() call. Create a new offline transaction for each later write; calling mutate() again on the committed transaction now throws TransactionNotPendingMutateError.
  • If you import @tanstack/db-ivm directly, update removed operator imports. The old join helpers become join(other, type); use orderByWithFractionalIndex or groupedOrderByWithFractionalIndex in place of the removed ordering helpers. See the 0.2.0 release notes for the full list.

Production error messages now contain a short code and a link to the error reference. Code that compared the old message text should use error classes, names, or fields instead. The development messages remain descriptive.

Update custom framework adapters

If you maintain a framework adapter using the unstable window-controller exports from 0.9.2, replace helpers such as resolveLiveQueryWindowInput and createLiveQueryWindowController with bindLiveQueryWindow for the shared infinite-query binding. Ordinary apps using the first-party hooks do not need this change.

Optional new features

Enable pooling for screens with many live queries

Pooling is a new performance option for screens with dozens or more live queries of the same shape, such as one query per list row. Eligible queries share work instead of compiling a separate graph for each query. To enable it, pass the exported pooledLiveQueries value in the query config:

tsx
import { eq, pooledLiveQueries } from '@tanstack/db'
import { useLiveQuery } from '@tanstack/react-db'

useLiveQuery({
  query: (q) => q.from({ todo: todoCollection })
    .where(({ todo }) => eq(todo.listId, listId)),
  pool: pooledLiveQueries,
})

Pooling supports queries that filter one eager source with eq(field, literal). Omit pool to keep each query independent. Results are the same either way. Suspense queries and queries backed by a DbClient do not pool. Angular's { params, query } form accepts the same pool value. See Pooled Live Queries.

Explore storage options

IndexedDB Collections are new since 0.9.2. Import createIndexedDB and indexedDBCollectionOptions from @tanstack/db to use them. See the IndexedDB Collection guide.

SQLite persistence has a new initialRender option for rendering restored data when the network is slow or unavailable. See Render after SQLite restore and On-demand Collections offline for configuration and limits.