
Skill
db-core/mutations-optimistic
manage optimistic mutations in TanStack DB
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.
SKILL.md
Mutations & Optimistic State
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.
Setup -- Collection Write Operations
insert
// 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 })
update (Immer-style draft proxy)
// 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'
},
)
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>({
// 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(),
])
},
},
)
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 }),
})
// 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 })
3. createTransaction -- manual batching
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().
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)),
)
// Query Collection refetches after the handler completes and awaits it.
},
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
// WRONG -- silently fails or throws
collection.update(id, { ...item, title: 'new' })
// CORRECT -- mutate the draft proxy
collection.update(id, (draft) => {
draft.title = 'new'
})
CRITICAL: Hallucinating mutation API signatures
The most common AI-generated errors:
- Inventing handler signatures (e.g.
onMutateon a collection config) - Confusing
createOptimisticActionwithcreateTransaction - Wrong PendingMutation property names (
mutation.datadoes not exist -- usemutation.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.
// WRONG
createOptimisticAction({
onMutate: async (text) => {
collection.insert({ id: await generateId(), text })
},
...
})
// CORRECT
createOptimisticAction({
onMutate: (text) => {
collection.insert({ id: crypto.randomUUID(), text })
},
...
})
CRITICAL: Mutations without handler or ambient transaction
Collection mutations require either:
- An
onInsert/onUpdate/onDeletehandler 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: 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.
// WRONG -- causes one manual and one automatic refetch
onInsert: async ({ transaction }) => {
await api.createTodo(transaction.mutations[0].modified)
await collection.utils.refetch()
}
// CORRECT -- automatic refetch is awaited after this returns
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.promiserejection to surface errors to the user
References
- Transaction API Reference -- createTransaction config, Transaction object, PendingMutation type, mutation merging rules, strategy types
- TanStack DB Mutations Guide
More skills from the db repository
View all 13 skillsangular-db
integrate TanStack DB with Angular
Jul 16AngularDatabaseTypeScriptdb-core
manage database collections with TanStack DB
Jul 17Data ModelingDatabaseTanStackdb-core/collection-setup
set up typed collections in TanStack DB
Jul 26Data ModelingDatabaseTanStackdb-core/custom-adapter
build custom TanStack collection adapters
Jul 16DatabaseEngineeringTanStackdb-core/live-queries
build live queries with TanStack DB
Jul 16DatabaseSQLTanStackdb-core/persistence
implement SQLite-backed persistence for TanStack DB
Jul 17DatabasePersistenceSQLiteTanStack
More from TanStack
View publisheraggregation
perform data aggregation in TanStack Table
table
Jul 26Data AnalysisFrontendTanStackapi-not-found
diagnose TanStack Table API errors
table
Jul 26DebuggingFrontendTanStackcell-selection
select rectangular cell ranges in tables
table
Jul 26Data AnalysisTanStackUI Componentsclient-vs-server
manage TanStack Table data pipelines
table
Jul 26Data PipelineFrontendPerformanceTanStackcolumn-faceting
build faceted filter UIs
table
Jul 26Data VisualizationFrontendTanStackcolumn-filtering
implement column filtering in TanStack Table
table
Jul 26Data AnalysisFrontendTanStack