| name | db-core/mutations-optimistic |
| description | 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.
|
| type | sub-skill |
| library | db |
| library_version | 0.6.17 |
| sources | ["TanStack/db:docs/guides/mutations.md","TanStack/db:packages/db/src/transactions.ts","TanStack/db:packages/db/src/optimistic-action.ts","TanStack/db:packages/db/src/paced-mutations.ts"] |
Mutations & Optimistic State
Depends on: db-core/collection-setup -- you need a configured collection
(with getKey, sync adapter, and optionally onInsert/onUpdate/onDelete
handlers) before you can mutate.
TanStack DB mutations follow a unidirectional loop:
optimistic mutation -> handler persists -> handler waits for sync/ack -> confirmed state.
Optimistic state is applied in the current tick and dropped when the handler resolves.
Setup -- Collection Write Operations
insert
import { safeRandomUUID } from '@tanstack/db'
todoCollection.insert({
id: safeRandomUUID(),
text: 'Buy groceries',
completed: false,
})
todoCollection.insert([
{ id: safeRandomUUID(), text: 'Buy groceries', completed: false },
{ id: safeRandomUUID(), text: 'Walk dog', completed: false },
])
todoCollection.insert(item, { metadata: { source: 'import' } })
todoCollection.insert(item, { optimistic: false })
update (Immer-style draft proxy)
todoCollection.update(todo.id, (draft) => {
draft.completed = true
draft.completedAt = new Date()
})
todoCollection.update([id1, id2], (drafts) => {
drafts.forEach((d) => {
d.completed = true
})
})
todoCollection.update(
todo.id,
{ metadata: { reason: 'user-edit' } },
(draft) => {
draft.text = 'Updated'
},
)
delete
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
settlement or catch rollback errors. For a non-empty transaction, this normally
means its mutationFn returned; it proves upload, confirmation, or read-back
only when that function waits for the backend observation before returning.
Core Patterns
1. createOptimisticAction -- intent-based mutations
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>({
onMutate: (postId) => {
postCollection.update(postId, (draft) => {
draft.likeCount += 1
draft.likedByMe = true
})
},
mutationFn: async (postId, { transaction }) => {
await api.posts.like(postId)
await postCollection.utils.refetch()
},
})
const tx = likePost(postId)
await tx.isPersisted.promise
Multi-collection example:
const createProject = createOptimisticAction<{ name: string; ownerId: string }>(
{
onMutate: ({ name, ownerId }) => {
projectCollection.insert({ id: safeRandomUUID(), 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(),
])
},
},
)
2. createPacedMutations -- auto-save with debounce / throttle / queue
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 }),
})
autoSaveNote('Hello')
autoSaveNote('Hello, world')
Other strategies:
import { throttleStrategy, queueStrategy } from '@tanstack/db'
throttleStrategy({ wait: 200, leading: true, trailing: true })
queueStrategy({ wait: 0, maxSize: 100 })
3. createTransaction -- manual batching
import { createTransaction } from '@tanstack/db'
const tx = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
await api.batchUpdate(transaction.mutations)
},
})
tx.mutate(() => {
todoCollection.update(id1, (d) => {
d.status = 'reviewed'
})
todoCollection.update(id2, (d) => {
d.status = 'reviewed'
})
})
await tx.commit()
Inside tx.mutate(() => { ... }), the transaction is pushed onto an ambient
stack. Any collection.insert/update/delete call joins the ambient transaction
automatically via getActiveTransaction(). That scope is synchronous:
collection operations after an await do not join it. Put async work in
mutationFn, or call mutate() again before committing.
For mutations captured by a manual transaction, collection-level
onInsert/onUpdate/onDelete handlers are not invoked automatically. The
manual transaction's mutationFn is responsible for persisting
transaction.mutations. This makes createTransaction({ autoCommit: false })
a good fit for draft-style flows where local state updates immediately but the
server call waits for Save/Blur; call tx.rollback() to discard the optimistic
changes.
4. Mutation handlers with automatic refetch (QueryCollection pattern)
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)),
)
},
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..(m..)),
)
},
}),
)
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 }
}
Common Mistakes
CRITICAL: Passing an object to update() instead of a draft callback
collection.update(id, { ...item, title: 'new' })
collection.update(id, (draft) => {
draft.title = 'new'
})
CRITICAL: Hallucinating mutation API signatures
The most common AI-generated errors:
- Inventing handler signatures (e.g.
onMutate on a collection config)
- Confusing
createOptimisticAction with createTransaction
- Wrong PendingMutation property names (
mutation.data does not exist --
use mutation.modified, mutation.changes, mutation.original)
- Missing the ambient transaction pattern
Always reference the exact types in references/transaction-api.md.
CRITICAL: onMutate returning a Promise
onMutate in createOptimisticAction must be synchronous. Optimistic state
is applied in the current tick. Returning a Promise throws
OnMutateMustBeSynchronousError.
createOptimisticAction({
onMutate: async (text) => {
collection.insert({ id: await generateId(), text })
},
...
})
createOptimisticAction({
onMutate: (text) => {
collection.insert({ id: safeRandomUUID(), text })
},
...
})
CRITICAL: Mutations without handler or ambient transaction
Collection mutations require either:
- An
onInsert/onUpdate/onDelete handler on the collection, OR
- An ambient transaction from
createTransaction/createOptimisticAction
Without either, throws MissingInsertHandlerError (or the Update/Delete variant).
HIGH: Calling .mutate() after transaction is no longer pending
Transactions only accept new mutations while in pending state. Calling
mutate() after commit() or rollback() throws
TransactionNotPendingMutateError. Create a new transaction instead.
HIGH: Changing primary key via update
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.
HIGH: Inserting item with duplicate key
If an item with the same key already exists (synced or optimistic), throws
DuplicateKeyError. Always generate a unique key (e.g. safeRandomUUID())
or check before inserting.
HIGH: Manually refetching inside a Query Collection handler
Query Collection automatically refetches after onInsert, onUpdate, and
onDelete complete, and waits for that refetch before the mutation finishes.
Calling utils.refetch() inside the handler sends a redundant request.
onInsert: async ({ transaction }) => {
await api.createTodo(transaction.mutations[0].modified)
await collection.utils.refetch()
}
onInsert: async ({ transaction }) => {
await api.createTodo(transaction.mutations[0].modified)
}
When the handler writes the confirmed server result with direct-write utilities,
return { refetch: false } to skip the automatic refetch.
Tension: Optimistic Speed vs. Data Consistency
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:
- Showing pending/saving indicators so users know state is unconfirmed
- Using
{ optimistic: false } for destructive operations
- Designing idempotent server endpoints so retries are safe
- Handling
tx.isPersisted.promise rejection to surface errors to the user
References