Skip to content

@tanstack/db

A reactive client store for building super fast apps on sync

db-core/mutations-optimistic

sub-skill
399 linesSource

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.when('settled').

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

ts
import { safeRandomUUID } from '@tanstack/db'

// Single item
todoCollection.insert({
  id: safeRandomUUID(),
  text: 'Buy groceries',
  completed: false,
})

// Multiple items
todoCollection.insert([
  { id: safeRandomUUID(), text: 'Buy groceries', completed: false },
  { id: safeRandomUUID(), 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.when('settled') 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.

Do not start or await collection preloads, live-query preloads, or direct loadSubset() calls inside mutationFn. Sync commits queue behind mutation persistence, so the preload can wait on the mutation that is waiting on it. Use the collection adapter's documented mutation acknowledgement pattern.


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.when('settled')

Multi-collection example:

ts
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

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(). 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 explicit refetch (QueryCollection pattern)

ts
const todoCollection = createCollection(
  queryCollectionOptions<{ id: string; text: string; completed: boolean }>({
    queryKey: ['todos'],
    queryClient,
    queryFn: () => api.todos.getAll(),
    getKey: (t) => t.id,
    onInsert: async ({ transaction, collection }) => {
      await Promise.all(
        transaction.mutations.map((m) => api.todos.create(m.modified)),
      )
      await collection.utils.refetch()
    },
    onUpdate: async ({ transaction, collection }) => {
      await Promise.all(
        transaction.mutations.map((m) =>
          api.todos.update(m.original.id, m.changes),
        ),
      )
      await collection.utils.refetch()
    },
    onDelete: async ({ transaction, collection }) => {
      await Promise.all(
        transaction.mutations.map((m) => api.todos.delete(m.original.id)),
      )
      await collection.utils.refetch()
    },
  }),
)

For ElectricCollection, wait for each txid in the Electric stream:

ts
onUpdate: async ({ transaction, collection }) => {
  const txids = await Promise.all(
    transaction.mutations.map(async (m) => {
      const res = await api.todos.update(m.original.id, m.changes)
      return res.txid
    }),
  )
  await Promise.all(txids.map((txid) => collection.utils.awaitTxId(txid)))
}

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: safeRandomUUID(), 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. safeRandomUUID()) or check before inserting.

HIGH: Returning before Query Collection has confirmed data

Query Collection does not refetch after onInsert, onUpdate, or onDelete. A server write alone leaves synced rows unchanged. When the handler returns, its optimistic state drops, so the previous synced rows reappear.

ts
// WRONG -- the optimistic insert disappears at settlement
onInsert: async ({ transaction }) => {
  await api.createTodo(transaction.mutations[0].modified)
}

// CORRECT -- explicitly read back the server state
onInsert: async ({ transaction, collection }) => {
  await api.createTodo(transaction.mutations[0].modified)
  await collection.utils.refetch()
}

Alternatively, await a direct-write utility with the confirmed server result. Handlers return Promise<void>; { refetch: false } is no longer supported.


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.when('settled') rejection to surface errors to the user

References