| 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.0 |
| 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 to backend -> sync back -> confirmed state.
Optimistic state is applied in the current tick and dropped when the handler resolves.
Setup -- Collection Write Operations
insert
todoCollection.insert({
id: crypto.randomUUID(),
text: 'Buy groceries',
completed: false,
})
todoCollection.insert([
{ id: crypto.randomUUID(), text: 'Buy groceries', completed: false },
{ id: crypto.randomUUID(), 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
persistence or catch rollback errors.
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: 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(),
])
},
},
)
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().
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 handler with 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.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 }
}
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: crypto.randomUUID(), 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. crypto.randomUUID())
or check before inserting.
HIGH: Not awaiting refetch after mutation in query collection handler
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.
onInsert: async ({ transaction }) => {
await api.createTodo(transaction.mutations[0].modified)
}
onInsert: async ({ transaction }) => {
await api.createTodo(transaction.mutations[0].modified)
await collection.utils.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