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 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:
npm install @tanstack/react-db@rc @tanstack/electric-db-collection@rcThe 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.
The $synced virtual row property has been removed. Its replacement has the opposite boolean meaning:
| Before | After |
|---|---|
| row.$synced | !row.$hasPendingWrites |
| !row.$synced | row.$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.
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.
Replace transaction.isPersisted.promise with transaction.when('settled'):
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.
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:
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:
// 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.
The accompanying Query Collection 1.x minor release removes automatic refetch from onInsert, onUpdate, and onDelete. Refresh explicitly inside each handler:
// 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.
These operators now need an explicit import so apps bundle only the operators they use.
A child query returned directly from .select() now throws IncludeOperatorRequiredError. Wrap it in include() to keep a live child Collection:
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.
Aggregates such as count() already include the grouping compiler. A query that groups without an aggregate must use groupKeys(), or it throws GroupingOperatorRequiredError:
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.
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.
// 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:
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.
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.
Solid useLiveQuery no longer exposes the deprecated .data alias. Call the returned accessor wherever you read the result:
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.
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.
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:
| Before | After |
|---|---|
| Hook isError | isLoadingError for a failed first load; isCrashed for a crash |
| status === 'error' for every failure | error only for a failed first load; crashed for local failure; health for sync problems after readiness |
| Hide rows on any sync error | Keep ready rows visible and use isSyncError for a stale-data banner |
| Query Collection utils.lastError, utils.isError, utils.errorCount | Read 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 Svelte | Read 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.
For a failed load or a stopped sync adapter, replace cleanup-and-preload retry code with reload():
// 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.
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:
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:
<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.
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.
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:
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.
See For sync adapter authors for an example and the full reload contract.
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:
// 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.
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.
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.
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.
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:
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.
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.