@tanstack/db
Version:
A reactive client store for building super fast apps on sync
385 lines (305 loc) • 11.2 kB
Markdown
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
```ts
// 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)
```ts
// 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
```ts
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.
```ts
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:
```ts
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
```ts
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:
```ts
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
```ts
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)
```ts
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:
```ts
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
```ts
// 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. `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`.
```ts
// 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:
1. An `onInsert`/`onUpdate`/`onDelete` handler on the collection, OR
2. 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.
```ts
// 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.promise` rejection to surface errors to the user
## References
- [Transaction API Reference](references/transaction-api.md) -- createTransaction config,
Transaction object, PendingMutation type, mutation merging rules, strategy types
- [TanStack DB Mutations Guide](https://tanstack.com/db/latest/docs/guides/mutations)