collection.insert, collection.update (Immer-style draft proxy), collection.delete. createOptimisticAction (onMutate + mutationFn). createPacedMutations with debounceStrategy, throttleStrategy, queueStrategy. createTransaction, getActiveTransaction, ambient transaction context. Transaction lifecycle (pending/persisting/completed/failed). Mutation merging. onInsert/onUpdate/onDelete handlers. PendingMutation type. Transaction.isPersisted.
Depends on:
db-core/collection-setup-- you need a configured collection (withgetKey, sync adapter, and optionallyonInsert/onUpdate/onDeletehandlers) before you can mutate.
TanStack DB mutations follow a unidirectional loop: optimistic mutation -> handler persists to backend -> sync back -> confirmed state. Optimistic state is applied in the current tick and dropped when the handler resolves.
// Single item
todoCollection.insert({
id: crypto.randomUUID(),
text: 'Buy groceries',
completed: false,
})
// Multiple items
todoCollection.insert([
{ id: crypto.randomUUID(), text: 'Buy groceries', completed: false },
{ id: crypto.randomUUID(), text: 'Walk dog', completed: false },
])
// With metadata / non-optimistic
todoCollection.insert(item, { metadata: { source: 'import' } })
todoCollection.insert(item, { optimistic: false })
// Single item -- mutate the draft, do NOT reassign it
todoCollection.update(todo.id, (draft) => {
draft.completed = true
draft.completedAt = new Date()
})
// Multiple items
todoCollection.update([id1, id2], (drafts) => {
drafts.forEach((d) => {
d.completed = true
})
})
// With metadata
todoCollection.update(
todo.id,
{ metadata: { reason: 'user-edit' } },
(draft) => {
draft.text = 'Updated'
},
)
todoCollection.delete(todo.id)
todoCollection.delete([id1, id2])
todoCollection.delete(todo.id, { metadata: { reason: 'completed' } })
All three return a Transaction object. Use tx.isPersisted.promise to await
persistence or catch rollback errors.
Use when the optimistic change is a guess at how the server will transform the data, or when you need to mutate multiple collections atomically.
import { createOptimisticAction } from '@tanstack/db'
const likePost = createOptimisticAction<string>({
// MUST be synchronous -- applied in the current tick
onMutate: (postId) => {
postCollection.update(postId, (draft) => {
draft.likeCount += 1
draft.likedByMe = true
})
},
mutationFn: async (postId, { transaction }) => {
await api.posts.like(postId)
// IMPORTANT: wait for server state to sync back before returning
await postCollection.utils.refetch()
},
})
// Returns a Transaction
const tx = likePost(postId)
await tx.isPersisted.promise
Multi-collection example:
const createProject = createOptimisticAction<{ name: string; ownerId: string }>(
{
onMutate: ({ name, ownerId }) => {
projectCollection.insert({ id: crypto.randomUUID(), name, ownerId })
userCollection.update(ownerId, (d) => {
d.projectCount += 1
})
},
mutationFn: async ({ name, ownerId }) => {
await api.projects.create({ name, ownerId })
await Promise.all([
projectCollection.utils.refetch(),
userCollection.utils.refetch(),
])
},
},
)
import { createPacedMutations, debounceStrategy } from '@tanstack/db'
const autoSaveNote = createPacedMutations<string>({
onMutate: (text) => {
noteCollection.update(noteId, (draft) => {
draft.body = text
})
},
mutationFn: async ({ transaction }) => {
const mutation = transaction.mutations[0]
await api.notes.update(mutation.key, mutation.changes)
await noteCollection.utils.refetch()
},
strategy: debounceStrategy({ wait: 500 }),
})
// Each call resets the debounce timer; mutations merge into one transaction
autoSaveNote('Hello')
autoSaveNote('Hello, world') // only this version persists
Other strategies:
import { throttleStrategy, queueStrategy } from '@tanstack/db'
// Evenly spaced (sliders, scroll)
throttleStrategy({ wait: 200, leading: true, trailing: true })
// Sequential FIFO -- every mutation persisted in order
queueStrategy({ wait: 0, maxSize: 100 })
import { createTransaction } from '@tanstack/db'
const tx = createTransaction({
autoCommit: false, // wait for explicit commit()
mutationFn: async ({ transaction }) => {
await api.batchUpdate(transaction.mutations)
},
})
tx.mutate(() => {
todoCollection.update(id1, (d) => {
d.status = 'reviewed'
})
todoCollection.update(id2, (d) => {
d.status = 'reviewed'
})
})
// User reviews... then commits or rolls back
await tx.commit()
// OR: tx.rollback()
Inside tx.mutate(() => { ... }), the transaction is pushed onto an ambient
stack. Any collection.insert/update/delete call joins the ambient transaction
automatically via getActiveTransaction().
const todoCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryFn: () => api.todos.getAll(),
getKey: (t) => t.id,
onInsert: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((m) => api.todos.create(m.modified)),
)
// IMPORTANT: handler must not resolve until server state is synced back
// QueryCollection auto-refetches after handler completes
},
onUpdate: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((m) =>
api.todos.update(m.original.id, m.changes),
),
)
},
onDelete: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((m) => api.todos.delete(m.original.id)),
)
},
}),
)
For ElectricCollection, return { txid } instead of refetching:
onUpdate: async ({ transaction }) => {
const txids = await Promise.all(
transaction.mutations.map(async (m) => {
const res = await api.todos.update(m.original.id, m.changes)
return res.txid
}),
)
return { txid: txids }
}
// WRONG -- silently fails or throws
collection.update(id, { ...item, title: 'new' })
// CORRECT -- mutate the draft proxy
collection.update(id, (draft) => {
draft.title = 'new'
})
The most common AI-generated errors:
onMutate on a collection config)createOptimisticAction with createTransactionmutation.data does not exist --
use mutation.modified, mutation.changes, mutation.original)Always reference the exact types in references/transaction-api.md.
onMutate in createOptimisticAction must be synchronous. Optimistic state
is applied in the current tick. Returning a Promise throws
OnMutateMustBeSynchronousError.
// WRONG
createOptimisticAction({
onMutate: async (text) => {
collection.insert({ id: await generateId(), text })
},
...
})
// CORRECT
createOptimisticAction({
onMutate: (text) => {
collection.insert({ id: crypto.randomUUID(), text })
},
...
})
Collection mutations require either:
onInsert/onUpdate/onDelete handler on the collection, ORcreateTransaction/createOptimisticActionWithout either, throws MissingInsertHandlerError (or the Update/Delete variant).
Transactions only accept new mutations while in pending state. Calling
mutate() after commit() or rollback() throws
TransactionNotPendingMutateError. Create a new transaction instead.
The update proxy detects key changes and throws KeyUpdateNotAllowedError.
Primary keys are immutable once set. If you need a different key, delete and
re-insert.
If an item with the same key already exists (synced or optimistic), throws
DuplicateKeyError. Always generate a unique key (e.g. crypto.randomUUID())
or check before inserting.
The optimistic state is held only until the handler resolves. If the handler returns before server state has synced back, optimistic state is dropped and users see a flash of missing data.
// WRONG -- optimistic state dropped before new server state arrives
onInsert: async ({ transaction }) => {
await api.createTodo(transaction.mutations[0].modified)
// missing: await collection.utils.refetch()
}
// CORRECT
onInsert: async ({ transaction }) => {
await api.createTodo(transaction.mutations[0].modified)
await collection.utils.refetch()
}
Instant optimistic updates create a window where client state diverges from server state. If the handler fails, the rollback removes the optimistic state -- which can discard user work the user thought was saved. Consider:
{ optimistic: false } for destructive operationstx.isPersisted.promise rejection to surface errors to the user