TanStack logo

Skill

db-core/mutations-optimistic

manage optimistic mutations in TanStack DB

Covers Database TanStack Frontend

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 (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

// 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. 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.

// 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.

// 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

© 2026 YourAI.tools. Every skill from an identity-verified publisher.

Independent catalog. Not affiliated with, endorsed by, or sponsored by Anthropic or any listed publisher. All trademarks belong to their respective owners.