# Mutations

TanStack DB provides a powerful mutation system that enables optimistic updates with automatic state management. This system is built around a pattern of **optimistic mutation → mutation handler → handler-defined settlement**. A handler can extend that boundary through backend confirmation by waiting for the write to sync back before it returns. This creates a highly responsive user experience while maintaining data consistency and being easy to reason about.

Local changes are applied immediately as optimistic state. When the mutation handler returns successfully while the transaction is still `persisting`, the transaction completes and the optimistic state is recomputed. Completion proves backend confirmation only when the handler explicitly waited for that confirmation or read-back.

```tsx
// Define a collection with a mutation handler
const todoCollection = createCollection({
  id: "todos",
  onUpdate: async ({ transaction }) => {
    const mutation = transaction.mutations[0]
    await api.todos.update(mutation.original.id, mutation.changes)
  },
})

// Apply an optimistic update
todoCollection.update(todo.id, (draft) => {
  draft.completed = true
})
```

This pattern extends the Redux/Flux unidirectional data flow beyond the client to include the server:

<figure>
  <a href="https://raw.githubusercontent.com/TanStack/db/main/docs/unidirectional-data-flow.lg.png" target="_blank">
    <img src="https://raw.githubusercontent.com/TanStack/db/main/docs/unidirectional-data-flow.png" />
  </a>
</figure>

The instant inner loop provides optimistic state. The slower outer loop is owned
by the mutation handler, which can persist to a server and wait for the updated
server state to sync back into the collection when that confirmation is
required.

### Simplified Mutations vs Traditional Approaches

TanStack DB's mutation system eliminates much of the boilerplate required for optimistic updates in traditional approaches. Compare the difference:

**Before (TanStack Query with manual optimistic updates):**

```typescript
const addTodoMutation = useMutation({
  mutationFn: async (newTodo) => api.todos.create(newTodo),
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    const previousTodos = queryClient.getQueryData(['todos'])
    queryClient.setQueryData(['todos'], (old) => [...(old || []), newTodo])
    return { previousTodos }
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos)
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})
```

**After (TanStack DB):**

```typescript
const todoCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: async () => api.todos.getAll(),
    getKey: (item) => item.id,
    schema: todoSchema,
    onInsert: async ({ transaction }) => {
      await Promise.all(
        transaction.mutations.map((mutation) =>
          api.todos.create(mutation.modified)
        )
      )
    },
  })
)

// Simple mutation - no boilerplate!
todoCollection.insert({
  id: crypto.randomUUID(),
  text: '🔥 Make app faster',
  completed: false,
})
```

The benefits:
- ✅ Automatic optimistic updates
- ✅ Automatic rollback on error
- ✅ No manual cache manipulation
- ✅ Type-safe mutations

## Table of Contents

- [Mutation Approaches](#mutation-approaches)
- [Mutation Lifecycle](#mutation-lifecycle)
- [Collection Write Operations](#collection-write-operations)
- [Operation Handlers](#operation-handlers)
- [Creating Custom Actions](#creating-custom-actions)
- [Manual Transactions](#manual-transactions)
- [Paced Mutations](#paced-mutations)
- [Mutation Merging](#mutation-merging)
- [Controlling Optimistic Behavior](#controlling-optimistic-behavior)
- [Transaction States](#transaction-states)
- [Handling Temporary IDs](#handling-temporary-ids)

## Mutation Approaches

TanStack DB provides different approaches to mutations, each suited to different use cases:

### Collection-Level Mutations

Collection-level mutations (`insert`, `update`, `delete`) are designed for **direct state manipulation** of a single collection. These are the simplest way to make changes and work well for straightforward CRUD operations.

```tsx
// Direct state change
todoCollection.update(todoId, (draft) => {
  draft.completed = true
  draft.completedAt = new Date()
})
```

Use collection-level mutations when:
- You're making simple CRUD operations on a single collection
- The state changes are straightforward and match what the server will store

You can use `metadata` to annotate these operations and customize behavior in your handlers:

```tsx
// Annotate with metadata
todoCollection.update(
  todoId,
  { metadata: { intent: 'complete' } },
  (draft) => {
    draft.completed = true
  }
)

// Use metadata in handler
onUpdate: async ({ transaction }) => {
  const mutation = transaction.mutations[0]

  if (mutation.metadata?.intent === 'complete') {
    await Promise.all(
      transaction.mutations.map((mutation) =>
        api.todos.complete(mutation.original.id)
      )
    )
  } else {
    await Promise.all(
      transaction.mutations.map((mutation) =>
        api.todos.update(mutation.original.id, mutation.changes)
      )
    )
  }
}
```

### Intent-Based Mutations with Custom Actions

For more complex scenarios, use `createOptimisticAction` to create **intent-based mutations** that capture specific user actions.

```tsx
// Intent: "like this post"
const likePost = createOptimisticAction<string>({
  onMutate: (postId) => {
    // Optimistic guess at the change
    postCollection.update(postId, (draft) => {
      draft.likeCount += 1
      draft.likedByMe = true
    })
  },
  mutationFn: async (postId) => {
    // Send the intent to the server
    await api.posts.like(postId)
    // Server determines actual state changes
    await postCollection.utils.refetch()
  },
})

// Use it.
likePost(postId)
```

Use custom actions when:
- You need to mutate **multiple collections** in a single transaction
- The optimistic change is a **guess** at how the server will transform the data
- You want to send **user intent** to the backend rather than exact state changes
- The server performs complex logic, calculations, or side effects
- You want a clean, reusable mutation that captures a specific operation

Custom actions provide the cleanest way to capture specific types of mutations as named operations in your application. While you can achieve similar results using metadata with collection-level mutations, custom actions make the intent explicit and keep related logic together.

**When to use each:**

- **Collection-level mutations** (`collection.update`): Simple CRUD operations on a single collection
- **`createOptimisticAction`**: Intent-based operations, multi-collection mutations, immediately committed
- **Bypass the mutation system**: Use your existing mutation logic without rewriting

### Bypass the Mutation System

If you already have mutation logic in an existing system and don't want to rewrite it, you can **completely bypass** TanStack DB's mutation system and use your existing patterns.

With this approach, you write to the server like normal using your existing logic, then use your collection's mechanism for refetching or syncing data to await the server write. After the sync completes, the collection will have the updated server data and you can render the new state, hide loading indicators, show success messages, navigate to a new page, etc.

```tsx
// Call your backend directly with your existing logic
const handleUpdateTodo = async (todoId, changes) => {
  await api.todos.update(todoId, changes)

  // Wait for the server change to load into the collection
  await todoCollection.utils.refetch()

  // Now you know the new data is loaded and can render it or hide loaders
}

// With Electric
const handleUpdateTodo = async (todoId, changes) => {
  const { txid } = await api.todos.update(todoId, changes)

  // Wait for this specific transaction to sync into the collection
  await todoCollection.utils.awaitTxId(txid)

  // Now the server change is loaded and you can update UI accordingly
}
```

Use this approach when:
- You have existing mutation logic you don't want to rewrite
- You're comfortable with your current mutation patterns
- You want to use TanStack DB only for queries and state management

How to sync changes back:
- **QueryCollection**: Manually refetch with `collection.utils.refetch()` to reload data from the server
- **ElectricCollection**: Use `collection.utils.awaitTxId(txid)` to wait for a specific transaction to sync
- **Other sync systems**: Wait for your sync mechanism to update the collection

## Mutation Lifecycle

The mutation lifecycle follows a consistent pattern across all mutation types:

1. **Optimistic state applied**: The mutation is immediately applied to the local collection as optimistic state
2. **Handler invoked**: The appropriate handler — either `mutationFn` or a Collection handler (`onInsert`, `onUpdate`, or `onDelete`) — is called to persist the change
3. **Handler-defined persistence**: Your handler performs the required local or backend work
4. **Optional confirmation**: If transaction completion must mean server confirmation, the handler waits for the provider's acknowledgement, read-back, or sync observation
5. **Successful transaction settlement**: If the handler returns while the transaction is still `persisting`, the transaction becomes `completed` and the visible state is recomputed from synced data and any remaining optimistic transactions

```tsx
// Step 1: Optimistic state applied immediately
todoCollection.update(todo.id, (draft) => {
  draft.completed = true
})
// UI updates instantly with optimistic state

// Step 2-3: onUpdate handler performs its persistence work
// Step 4: The handler may wait for sync back when confirmation is required
// Step 5: Handler return settles the transaction and recomputes visible state
```

If the handler throws an error during persistence, the optimistic state is automatically rolled back.

`tx.isPersisted.promise` observes this transaction-settlement boundary. Despite
the property name, it does not by itself prove that a server uploaded,
confirmed, or returned the write. It proves those stronger guarantees only
when the handler waits for that backend observation before returning.

### Concurrent Optimistic Transactions

Separate transactions that update the same row are layered as whole-row
snapshots. The latest contributing whole-row snapshot supplies the visible row;
fields are not independently merged from whichever transactions remain
pending. Each later `update()` draft starts from the current visible state,
including optimistic changes from earlier transactions, so edits can build on
one another.

```tsx
const tx1 = docCollection.update(docId, (draft) => {
  draft.content = 'A'
})
const tx2 = docCollection.update(docId, (draft) => {
  // This draft sees content = 'A'.
  draft.content = 'B'
})
const tx3 = docCollection.update(docId, (draft) => {
  // This draft sees content = 'B'.
  draft.content = 'C'
})

// Visible content is now 'C'. If tx1 and then tx2 settle while tx3 is still
// active, 'C' remains visible.
```

If a newer transaction stops contributing while an older one is still active,
the older transaction's snapshot can become visible again until that older
transaction itself settles or rolls back. Do not infer server execution order
from the local layering order. Multiple writes to the same row inside one
manual transaction are different: they merge into that transaction's single
pending mutation according to the
[mutation-merging rules](#mutation-merging).

## Collection Write Operations

Collections support three core write operations: `insert`, `update`, and `delete`. Each operation applies optimistic state immediately and triggers the corresponding operation handler.

### Insert

Add new items to a collection:

```typescript
// Insert a single item
todoCollection.insert({
  id: "1",
  text: "Buy groceries",
  completed: false
})

// Insert multiple items
todoCollection.insert([
  { id: "1", text: "Buy groceries", completed: false },
  { id: "2", text: "Walk dog", completed: false },
])

// Insert with metadata
todoCollection.insert(
  { id: "1", text: "Custom item", completed: false },
  { metadata: { source: "import" } }
)

// Insert without optimistic updates
todoCollection.insert(
  { id: "1", text: "Server-validated item", completed: false },
  { optimistic: false }
)
```

**Returns**: A `Transaction` object that you can use to track the mutation's lifecycle.

### Update

Modify existing items using an immutable draft pattern:

```typescript
// Update a single item
todoCollection.update(todo.id, (draft) => {
  draft.completed = true
})

// Update multiple items
todoCollection.update([todo1.id, todo2.id], (drafts) => {
  drafts.forEach((draft) => {
    draft.completed = true
  })
})

// Update with metadata
todoCollection.update(
  todo.id,
  { metadata: { reason: "user update" } },
  (draft) => {
    draft.text = "Updated text"
  }
)

// Update without optimistic updates
todoCollection.update(
  todo.id,
  { optimistic: false },
  (draft) => {
    draft.status = "server-validated"
  }
)
```

**Parameters**:
- `key` or `keys`: The item key(s) to update
- `options` (optional): Configuration object with `metadata` and/or `optimistic` flags
- `updater`: Function that receives a draft to mutate

**Returns**: A `Transaction` object that you can use to track the mutation's lifecycle.

> [!IMPORTANT]
> The `updater` function uses an Immer-like pattern to capture changes as immutable updates. You must not reassign the draft parameter itself—only mutate its properties.

Existing row values stay isolated from draft edits. New objects you assign or
add to a draft keep normal shared references during the synchronous callback:

```ts
const tag = { label: 'new' }
todoCollection.update(todoId, (draft) => {
  draft.tags.add(tag) // tags is a Set
  tag.label = 'edited' // included in the update
  for (const value of draft.tags) value.label = 'final'
  // tag.label is now 'final' too
})
tag.label = 'later' // does not change the stored row
```

The completed changes are copied when the callback returns. This applies to
new Map values, Set members, and objects assigned to draft properties. If you
need to keep a new caller-owned object unchanged during the callback, insert
your own copy. A thrown callback does not roll back edits to that caller-owned
object; it leaves existing collection data unchanged.

Arbitrary class instances are an exception: newly assigned instances stay by
reference so their methods, prototypes, and private fields remain intact.
Later changes to such an instance can therefore affect stored data without a
new update or notification. Treat those instances as immutable, or convert them
to plain data before assignment when you need isolation. Supported native values
such as `URL`, `Date`, `RegExp`, and typed arrays are copied instead.

### Delete

Remove items from a collection:

```typescript
// Delete a single item
todoCollection.delete(todo.id)

// Delete multiple items
todoCollection.delete([todo1.id, todo2.id])

// Delete with metadata
todoCollection.delete(todo.id, {
  metadata: { reason: "completed" }
})

// Delete without optimistic updates
todoCollection.delete(todo.id, { optimistic: false })
```

**Parameters**:
- `key` or `keys`: The item key(s) to delete
- `options` (optional): Configuration object with `metadata` and/or `optimistic` flags

**Returns**: A `Transaction` object that you can use to track the mutation's lifecycle.

## Operation Handlers

Operation handlers are functions you provide when creating a collection that handle persisting mutations to your backend. Each collection can define three optional handlers: `onInsert`, `onUpdate`, and `onDelete`.

### Handler Signature

All operation handlers receive an object with the following properties:

```typescript
type OperationHandler = (params: {
  transaction: Transaction
  collection: Collection
}) => Promise<any> | any
```

The `transaction` object contains:
- `mutations`: Array of mutation objects, each with:
  - `collection`: The collection being mutated
  - `type`: The mutation type (`'insert'`, `'update'`, or `'delete'`)
  - `original`: The original item (for updates and deletes)
  - `modified`: The modified item (for inserts and updates)
  - `changes`: The changes object (for updates)
  - `key`: The item key
  - `metadata`: Optional metadata attached to the mutation

### Defining Operation Handlers

Define handlers when creating a collection:

```typescript
const todoCollection = createCollection({
  id: "todos",
  // ... other options

  onInsert: async ({ transaction }) => {
    await Promise.all(
      transaction.mutations.map((mutation) =>
        api.todos.create(mutation.modified)
      )
    )
  },

  onUpdate: async ({ transaction }) => {
    await Promise.all(
      transaction.mutations.map((mutation) =>
        api.todos.update(mutation.original.id, mutation.changes)
      )
    )
  },

  onDelete: async ({ transaction }) => {
    await Promise.all(
      transaction.mutations.map((mutation) =>
        api.todos.delete(mutation.original.id)
      )
    )
  },
})
```

> [!IMPORTANT]
> Operation handlers must not resolve until the server changes have synced back to the collection. Different collection types provide different patterns to ensure this happens correctly.
>
> Do not call or await `collection.preload()`, live-query `preload()`, or a
> direct `loadSubset()` inside a mutation handler. The optimistic mutation is
> already applied when the handler starts. A preload may need a sync commit
> that is queued behind that same handler, which creates a deadlock. Use the
> collection adapter's documented mutation acknowledgement pattern instead.

### Collection-Specific Handler Patterns

Different collection types have specific patterns for their handlers:

**QueryCollection** - automatically refetches after handler completes:
```typescript
onUpdate: async ({ transaction }) => {
  await Promise.all(
    transaction.mutations.map((mutation) =>
      api.todos.update(mutation.original.id, mutation.changes)
    )
  )
  // Automatic refetch happens after handler completes
}
```

**ElectricCollection** - return txid(s) to track sync:
```typescript
onUpdate: async ({ transaction }) => {
  const txids = await Promise.all(
    transaction.mutations.map(async (mutation) => {
      const response = await api.todos.update(mutation.original.id, mutation.changes)
      return response.txid
    })
  )
  return { txid: txids }
}
```

### Generic Mutation Functions

You can define a single mutation function for your entire app:

```typescript
import type { MutationFn } from "@tanstack/react-db"

const mutationFn: MutationFn = async ({ transaction }) => {
  const response = await api.mutations.batch(transaction.mutations)

  if (!response.ok) {
    throw new Error(`HTTP Error: ${response.status}`)
  }
}

// Use in collections
const todoCollection = createCollection({
  id: "todos",
  onInsert: mutationFn,
  onUpdate: mutationFn,
  onDelete: mutationFn,
})
```

### Schema Validation in Mutation Handlers

When a schema is configured for a collection, TanStack DB automatically validates and transforms data during mutations. The mutation handlers receive the **transformed data** (TOutput), not the raw input.

```typescript
const todoSchema = z.object({
  id: z.string(),
  text: z.string(),
  created_at: z.string().transform(val => new Date(val))  // TInput: string, TOutput: Date
})

const collection = createCollection({
  schema: todoSchema,
  onInsert: async ({ transaction }) => {
    const item = transaction.mutations[0].modified

    // item.created_at is already a Date object (TOutput)
    console.log(item.created_at instanceof Date)  // true

    // If your API needs a string, serialize it
    await api.todos.create({
      ...item,
      created_at: item.created_at.toISOString()  // Date → string
    })
  }
})

// User provides string (TInput)
collection.insert({
  id: "1",
  text: "Task",
  created_at: "2024-01-01T00:00:00Z"
})
```

**Key points:**
- Schema validation happens **before** mutation handlers are called
- Handlers receive **TOutput** (transformed data)
- If your backend needs a different format, serialize in the handler
- Schema validation errors throw `SchemaValidationError` before handlers run

For comprehensive documentation on schema validation and transformations, see the [Schemas guide](./schemas.md).

## Creating Custom Actions

For more complex mutation patterns, use `createOptimisticAction` to create custom actions with full control over the mutation lifecycle.

### Basic Action

Create an action that combines mutation logic with persistence:

```tsx
import { createOptimisticAction } from "@tanstack/react-db"

const addTodo = createOptimisticAction<string>({
  onMutate: (text) => {
    // Apply optimistic state
    todoCollection.insert({
      id: crypto.randomUUID(),
      text,
      completed: false,
    })
  },
  mutationFn: async (text, params) => {
    // Persist to backend
    const response = await fetch("/api/todos", {
      method: "POST",
      body: JSON.stringify({ text, completed: false }),
    })
    const result = await response.json()

    // Wait for sync back
    await todoCollection.utils.refetch()

    return result
  },
})

// Use in components
const Todo = () => {
  const handleClick = () => {
    addTodo("🔥 Make app faster")
  }

  return <Button onClick={handleClick} />
}
```

### Type-Safe Actions with Schema Validation

For better type safety and runtime validation, you can use schema validation libraries like Zod, Valibot, or others. Here's an example using Zod:

```tsx
import { createOptimisticAction } from "@tanstack/react-db"
import { z } from "zod"

// Define a schema for the action parameters
const addTodoSchema = z.object({
  text: z.string().min(1, "Todo text cannot be empty"),
  priority: z.enum(["low", "medium", "high"]).optional(),
})

// Use the schema's inferred type for the generic
const addTodo = createOptimisticAction<z.infer<typeof addTodoSchema>>({
  onMutate: (params) => {
    // Validate parameters at runtime
    const validated = addTodoSchema.parse(params)

    // Apply optimistic state
    todoCollection.insert({
      id: crypto.randomUUID(),
      text: validated.text,
      priority: validated.priority ?? "medium",
      completed: false,
    })
  },
  mutationFn: async (params) => {
    // Parameters are already validated
    const validated = addTodoSchema.parse(params)

    const response = await fetch("/api/todos", {
      method: "POST",
      body: JSON.stringify({
        text: validated.text,
        priority: validated.priority ?? "medium",
        completed: false,
      }),
    })
    const result = await response.json()

    await todoCollection.utils.refetch()
    return result
  },
})

// Use with type-safe parameters
const Todo = () => {
  const handleClick = () => {
    addTodo({
      text: "🔥 Make app faster",
      priority: "high",
    })
  }

  return <Button onClick={handleClick} />
}
```

This pattern works with any validation library (Zod, Valibot, Yup, etc.) and provides:
- ✅ Runtime validation of parameters
- ✅ Type safety from inferred types
- ✅ Clear error messages for invalid inputs
- ✅ Single source of truth for parameter shape

### Complex Multi-Collection Actions

Actions can mutate multiple collections:

```tsx
const createProject = createOptimisticAction<{
  name: string
  ownerId: string
}>({
  onMutate: ({ name, ownerId }) => {
    const projectId = crypto.randomUUID()

    // Insert project
    projectCollection.insert({
      id: projectId,
      name,
      ownerId,
      createdAt: new Date(),
    })

    // Update user's project count
    userCollection.update(ownerId, (draft) => {
      draft.projectCount += 1
    })
  },
  mutationFn: async ({ name, ownerId }) => {
    const response = await api.projects.create({ name, ownerId })

    // Wait for both collections to sync
    await Promise.all([
      projectCollection.utils.refetch(),
      userCollection.utils.refetch(),
    ])

    return response
  },
})
```

### Action Parameters

The `mutationFn` receives additional parameters for advanced use cases:

```tsx
const updateTodo = createOptimisticAction<{
  id: string
  changes: Partial<Todo>
}>({
  onMutate: ({ id, changes }) => {
    todoCollection.update(id, (draft) => {
      Object.assign(draft, changes)
    })
  },
  mutationFn: async ({ id, changes }, params) => {
    // params.transaction contains the transaction object
    // params.signal is an AbortSignal for cancellation

    const response = await api.todos.update(id, changes, {
      signal: params.signal,
    })

    await todoCollection.utils.refetch()
    return response
  },
})
```

## Manual Transactions

For maximum control over transaction lifecycles, create transactions manually using `createTransaction`. This approach allows you to batch multiple mutations, implement custom commit workflows, or create transactions that span multiple user interactions.

### Basic Manual Transaction

```ts
import { createTransaction } from "@tanstack/react-db"

const addTodoTx = createTransaction({
  autoCommit: false,
  mutationFn: async ({ transaction }) => {
    // Persist all mutations to backend
    await Promise.all(
      transaction.mutations.map((mutation) =>
        api.saveTodo(mutation.modified)
      )
    )
  },
})

// Apply first change
addTodoTx.mutate(() =>
  todoCollection.insert({
    id: "1",
    text: "First todo",
    completed: false
  })
)

// User reviews change...

// Apply another change
addTodoTx.mutate(() =>
  todoCollection.insert({
    id: "2",
    text: "Second todo",
    completed: false
  })
)

// User commits when ready (e.g., when they hit save)
addTodoTx.commit()
```

### Transaction Configuration

Manual transactions accept the following options:

```typescript
createTransaction({
  id?: string,              // Optional unique identifier for the transaction
  autoCommit?: boolean,     // Whether to automatically commit after mutate()
  mutationFn: MutationFn,   // Function to persist mutations
  metadata?: Record<string, unknown>, // Optional custom metadata
})
```

**autoCommit**:
- `true` (default): Transaction commits immediately after each `mutate()` call
- `false`: Transaction waits for explicit `commit()` call

### Transaction Methods

Manual transactions provide several methods:

```typescript
// Apply mutations within a transaction
tx.mutate(() => {
  collection.insert(item)
  collection.update(key, updater)
})

// Commit the transaction
await tx.commit()

// Manually rollback changes (e.g., user cancels a form)
// Note: Rollback happens automatically if mutationFn throws an error
tx.rollback()
```

When you call collection methods inside `tx.mutate()`, the mutations are
captured by the manual transaction. The collection's `onInsert`, `onUpdate`, and
`onDelete` handlers are not invoked for those mutations; the
manual transaction's `mutationFn` is responsible for persisting
`transaction.mutations`.

This makes manual transactions a good fit for draft-style workflows where local
state should update immediately, but persistence should wait for a later user
action such as Save or Blur. Each `tx.mutate()` call updates the optimistic
state instantly, so the UI reflects changes as the user types. When the user
triggers Save, `tx.commit()` fires the `mutationFn` to persist all accumulated
mutations in a single batch. If the user cancels instead, `tx.rollback()`
discards the optimistic changes and reverts the UI.

### Multi-Step Workflows

Manual transactions excel at complex workflows:

```ts
const reviewTx = createTransaction({
  autoCommit: false,
  mutationFn: async ({ transaction }) => {
    await api.batchUpdate(transaction.mutations)
  },
})

// Step 1: User makes initial changes
reviewTx.mutate(() => {
  todoCollection.update(id1, (draft) => {
    draft.status = "reviewed"
  })
  todoCollection.update(id2, (draft) => {
    draft.status = "reviewed"
  })
})

// Step 2: Show preview to user...

// Step 3: User confirms or makes additional changes
reviewTx.mutate(() => {
  todoCollection.update(id3, (draft) => {
    draft.status = "reviewed"
  })
})

// Step 4: User commits all changes at once
await reviewTx.commit()
// OR user cancels
// reviewTx.rollback()
```

### Using with Local Collections

LocalOnly and LocalStorage collections require special handling when used with manual transactions. Unlike server-synced collections that have `onInsert`, `onUpdate`, and `onDelete` handlers automatically invoked, local collections need you to manually accept mutations by calling `utils.acceptMutations()` in your transaction's `mutationFn`.

#### Why This Is Needed

Local collections (LocalOnly and LocalStorage) don't participate in the standard mutation handler flow for manual transactions. They need an explicit call to persist changes made during `tx.mutate()`.

#### Basic Usage

```ts
import { createTransaction } from "@tanstack/react-db"
import { localOnlyCollectionOptions } from "@tanstack/react-db"

const formDraft = createCollection(
  localOnlyCollectionOptions({
    id: "form-draft",
    getKey: (item) => item.id,
  })
)

const tx = createTransaction({
  autoCommit: false,
  mutationFn: async ({ transaction }) => {
    // Make API call with the data first
    const draftData = transaction.mutations
      .filter((m) => m.collection === formDraft)
      .map((m) => m.modified)

    await api.saveDraft(draftData)

    // After API succeeds, accept and persist local collection mutations
    formDraft.utils.acceptMutations(transaction)
  },
})

// Apply mutations
tx.mutate(() => {
  formDraft.insert({ id: "1", field: "value" })
})

// Commit when ready
await tx.commit()
```

#### Combining Local and Server Collections

You can mix local and server collections in the same transaction:

```ts
const localSettings = createCollection(
  localStorageCollectionOptions({
    id: "user-settings",
    storageKey: "app-settings",
    getKey: (item) => item.id,
  })
)

const userProfile = createCollection(
  queryCollectionOptions({
    queryKey: ["profile"],
    queryFn: async () => api.profile.get(),
    getKey: (item) => item.id,
    onUpdate: async ({ transaction }) => {
      await api.profile.update(transaction.mutations[0].modified)
    },
  })
)

const tx = createTransaction({
  mutationFn: async ({ transaction }) => {
    // Handle server collection mutations explicitly in mutationFn
    await Promise.all(
      transaction.mutations
        .filter((m) => m.collection === userProfile)
        .map((m) => api.profile.update(m.modified))
    )

    // After server mutations succeed, accept local collection mutations
    localSettings.utils.acceptMutations(transaction)
  },
})

// Update both local and server data in one transaction
tx.mutate(() => {
  localSettings.update("theme", (draft) => {
    draft.mode = "dark"
  })
  userProfile.update("user-1", (draft) => {
    draft.name = "Updated Name"
  })
})

await tx.commit()
```

#### Transaction Ordering

**When to call `acceptMutations`** matters for transaction semantics:

**After API success (recommended for consistency):**
```ts
mutationFn: async ({ transaction }) => {
  await api.save(data)  // API call first
  localData.utils.acceptMutations(transaction)  // Persist after success
}
```

✅ **Pros**: If the API fails, local changes roll back too (all-or-nothing semantics)
❌ **Cons**: Local state won't reflect changes until API succeeds

**Before API call (for independent local state):**
```ts
mutationFn: async ({ transaction }) => {
  localData.utils.acceptMutations(transaction)  // Persist first
  await api.save(data)  // Then API call
}
```

✅ **Pros**: Local state persists immediately, regardless of API outcome
❌ **Cons**: API failure leaves local changes persisted (divergent state)

Choose based on whether your local data should be independent of or coupled to remote mutations.

#### Best Practices

- Always call `utils.acceptMutations()` for local collections in manual transactions
- Call `acceptMutations` **after** API success if you want transactional consistency
- Call `acceptMutations` **before** API calls if local state should persist regardless
- Filter mutations by collection if you need to process them separately
- Mix local and server collections freely in the same transaction

### Listening to Transaction Lifecycle

Monitor transaction state changes:

```typescript
const tx = createTransaction({
  autoCommit: false,
  mutationFn: async ({ transaction }) => {
    await api.persist(transaction.mutations)
  },
})

// Wait for the transaction handler to settle
tx.isPersisted.promise.then(() => {
  console.log("Transaction completed!")
})

// Check current state
console.log(tx.state) // 'pending', 'persisting', 'completed', or 'failed'
```

## Paced Mutations

Paced mutations provide fine-grained control over **when and how** mutations are persisted to your backend. Instead of persisting every mutation immediately, you can use timing strategies to batch, delay, or queue mutations based on your application's needs.

Powered by [TanStack Pacer](https://github.com/TanStack/pacer), paced mutations are ideal for scenarios like:
- **Auto-save forms** that wait for the user to stop typing
- **Slider controls** that need smooth updates without overwhelming the backend
- **Sequential workflows** where order matters and every mutation must persist

### Key Design

The fundamental difference between strategies is how they handle transactions:

**Debounce/Throttle**: Only one pending transaction (collecting mutations) and one persisting transaction (writing to backend) at a time. Multiple rapid mutations automatically merge together into a single transaction.

**Queue**: Each mutation creates a separate transaction, guaranteed to run in the order they're made (FIFO by default, configurable to LIFO). All mutations are guaranteed to persist.

### Available Strategies

| Strategy | Behavior | Best For |
|----------|----------|----------|
| **`debounceStrategy`** | Wait for inactivity before persisting. Only final state is saved. | Auto-save forms, search-as-you-type |
| **`throttleStrategy`** | Ensure minimum spacing between executions. Mutations between executions are merged. | Sliders, progress updates, analytics |
| **`queueStrategy`** | Each mutation becomes a separate transaction, processed sequentially in order (FIFO by default, configurable to LIFO). All mutations guaranteed to persist. | Sequential workflows, file uploads, rate-limited APIs |

### Debounce Strategy

The debounce strategy waits for a period of inactivity before persisting. This is perfect for auto-save scenarios where you want to wait until the user stops typing before saving their work.

```tsx
import { usePacedMutations, debounceStrategy } from "@tanstack/react-db"

function AutoSaveForm({ formId }: { formId: string }) {
  const mutate = usePacedMutations<{ field: string; value: string }>({
    onMutate: ({ field, value }) => {
      // Apply optimistic update immediately
      formCollection.update(formId, (draft) => {
        draft[field] = value
      })
    },
    mutationFn: async ({ transaction }) => {
      // Persist the final merged state to the backend
      await api.forms.save(transaction.mutations)
    },
    // Wait 500ms after the last change before persisting
    strategy: debounceStrategy({ wait: 500 }),
  })

  const handleChange = (field: string, value: string) => {
    // Multiple rapid changes merge into a single transaction
    mutate({ field, value })
  }

  return (
    <form>
      <input onChange={(e) => handleChange('title', e.target.value)} />
      <textarea onChange={(e) => handleChange('content', e.target.value)} />
    </form>
  )
}
```

**Key characteristics**:
- Timer resets on each mutation
- Only the final merged state persists
- Reduces backend writes significantly for rapid changes

### Throttle Strategy

The throttle strategy ensures a minimum spacing between executions. This is ideal for scenarios like sliders or progress updates where you want smooth, consistent updates without overwhelming your backend.

```tsx
import { usePacedMutations, throttleStrategy } from "@tanstack/react-db"

function VolumeSlider() {
  const mutate = usePacedMutations<number>({
    onMutate: (volume) => {
      // Apply optimistic update immediately
      settingsCollection.update('volume', (draft) => {
        draft.value = volume
      })
    },
    mutationFn: async ({ transaction }) => {
      await api.settings.updateVolume(transaction.mutations)
    },
    // Persist at most once every 200ms
    strategy: throttleStrategy({
      wait: 200,
      leading: true,   // Execute immediately on first call
      trailing: true,  // Execute after wait period if there were mutations
    }),
  })

  const handleVolumeChange = (volume: number) => {
    mutate(volume)
  }

  return (
    <input
      type="range"
      min={0}
      max={100}
      onChange={(e) => handleVolumeChange(Number(e.target.value))}
    />
  )
}
```

**Key characteristics**:
- Guarantees minimum spacing between persists
- Can execute on leading edge, trailing edge, or both
- Mutations between executions are merged

### Queue Strategy

The queue strategy creates a separate transaction for each mutation and processes them sequentially in order. Unlike debounce/throttle which may drop intermediate mutations, **every mutation is guaranteed to be attempted**, making it ideal for workflows where you can't skip any operations.

```tsx
import { usePacedMutations, queueStrategy } from "@tanstack/react-db"

function FileUploader() {
  const mutate = usePacedMutations<File>({
    onMutate: (file) => {
      // Apply optimistic update immediately
      uploadCollection.insert({
        id: crypto.randomUUID(),
        file,
        status: 'pending',
      })
    },
    mutationFn: async ({ transaction }) => {
      // Each file upload is its own transaction
      const mutation = transaction.mutations[0]
      await api.files.upload(mutation.modified)
    },
    // Process each upload sequentially with 500ms between them
    strategy: queueStrategy({
      wait: 500,
      addItemsTo: 'back',    // FIFO: add to back of queue
      getItemsFrom: 'front', // FIFO: process from front of queue
    }),
  })

  const handleFileSelect = (files: FileList) => {
    // Each file creates its own transaction, queued for sequential processing
    Array.from(files).forEach((file) => {
      mutate(file)
    })
  }

  return <input type="file" multiple onChange={(e) => handleFileSelect(e.target.files!)} />
}
```

**Key characteristics**:
- Each mutation becomes its own transaction
- Processes sequentially in order (FIFO by default)
- Can configure to LIFO by setting `getItemsFrom: 'back'`
- All mutations guaranteed to be attempted (unlike debounce/throttle which may skip intermediate mutations)
- Waits for each transaction to complete before starting the next

**Error handling**:
- If a mutation fails, **it is not automatically retried** - the transaction transitions to "failed" state
- Failed mutations surface their error via `transaction.isPersisted.promise` (which will reject)
- **Subsequent mutations continue processing** - a single failure does not block the queue
- Each mutation is independent; there is no all-or-nothing transaction semantics across multiple mutations
- To implement retry logic, see [Retry Behavior](#retry-behavior)

### Choosing a Strategy

Use this guide to pick the right strategy for your use case:

**Use `debounceStrategy` when:**
- You want to wait for the user to finish their action
- Only the final state matters (intermediate states can be discarded)
- You want to minimize backend writes
- Examples: auto-save forms, search-as-you-type, settings panels

**Use `throttleStrategy` when:**
- You want smooth, consistent updates at a controlled rate
- Some intermediate states should persist, but not all
- You need updates to feel responsive without overwhelming the backend
- Examples: volume sliders, progress bars, analytics tracking, live cursor position

**Use `queueStrategy` when:**
- Every mutation must persist (no operations can be lost)
- Order of operations matters
- You're working with a rate-limited API
- You need sequential processing with delays
- Examples: file uploads, batch operations, audit trails, multi-step wizards

### Using in React

The `usePacedMutations` hook makes it easy to use paced mutations in React components:

```tsx
import { usePacedMutations, debounceStrategy } from "@tanstack/react-db"

function MyComponent({ itemId }: { itemId: string }) {
  const mutate = usePacedMutations<number>({
    onMutate: (newValue) => {
      // Apply optimistic update immediately
      collection.update(itemId, (draft) => {
        draft.value = newValue
      })
    },
    mutationFn: async ({ transaction }) => {
      await api.save(transaction.mutations)
    },
    strategy: debounceStrategy({ wait: 500 }),
  })

  // Each mutate call returns a Transaction you can await
  const handleSave = async (newValue: number) => {
    const tx = mutate(newValue)

    // Optionally wait for handler settlement
    try {
      await tx.isPersisted.promise
      console.log('Transaction completed!')
    } catch (error) {
      console.error('Save failed:', error)
    }
  }

  return <button onClick={() => handleSave(42)}>Save</button>
}
```

The hook automatically memoizes the strategy and mutation function to prevent unnecessary recreations. You can also use `createPacedMutations` directly outside of React:

```ts
import { createPacedMutations, queueStrategy } from "@tanstack/db"

const mutate = createPacedMutations<{ id: string; changes: Partial<Item> }>({
  onMutate: ({ id, changes }) => {
    // Apply optimistic update immediately
    collection.update(id, (draft) => {
      Object.assign(draft, changes)
    })
  },
  mutationFn: async ({ transaction }) => {
    await api.save(transaction.mutations)
  },
  strategy: queueStrategy({ wait: 200 }),
})

// Use anywhere in your application
mutate({ id: '123', changes: { name: 'New Name' } })
```

### Understanding Queues and Hook Instances

**Each unique `usePacedMutations` hook call creates its own independent queue.** This is an important design decision that affects how you structure your mutations.

If you have multiple components calling `usePacedMutations` separately, each will have its own isolated queue:

```tsx
function EmailDraftEditor1({ draftId }: { draftId: string }) {
  // This creates Queue A
  const mutate = usePacedMutations({
    onMutate: (text) => {
      draftCollection.update(draftId, (draft) => {
        draft.text = text
      })
    },
    mutationFn: async ({ transaction }) => {
      await api.saveDraft(transaction.mutations)
    },
    strategy: debounceStrategy({ wait: 500 }),
  })

  return <textarea onChange={(e) => mutate(e.target.value)} />
}

function EmailDraftEditor2({ draftId }: { draftId: string }) {
  // This creates Queue B (separate from Queue A)
  const mutate = usePacedMutations({
    onMutate: (text) => {
      draftCollection.update(draftId, (draft) => {
        draft.text = text
      })
    },
    mutationFn: async ({ transaction }) => {
      await api.saveDraft(transaction.mutations)
    },
    strategy: debounceStrategy({ wait: 500 }),
  })

  return <textarea onChange={(e) => mutate(e.target.value)} />
}
```

In this example, mutations from `EmailDraftEditor1` and `EmailDraftEditor2` will be queued and processed **independently**. They won't share the same debounce timer or queue.

**To share the same queue across multiple components**, create a single `createPacedMutations` instance and use it everywhere:

```tsx
// Create a single shared instance
import { createPacedMutations, debounceStrategy } from "@tanstack/db"

export const mutateDraft = createPacedMutations<{ draftId: string; text: string }>({
  onMutate: ({ draftId, text }) => {
    draftCollection.update(draftId, (draft) => {
      draft.text = text
    })
  },
  mutationFn: async ({ transaction }) => {
    await api.saveDraft(transaction.mutations)
  },
  strategy: debounceStrategy({ wait: 500 }),
})

// Now both components share the same queue
function EmailDraftEditor1({ draftId }: { draftId: string }) {
  return <textarea onChange={(e) => mutateDraft({ draftId, text: e.target.value })} />
}

function EmailDraftEditor2({ draftId }: { draftId: string }) {
  return <textarea onChange={(e) => mutateDraft({ draftId, text: e.target.value })} />
}
```

With this approach, all mutations from both components share the same debounce timer and queue, ensuring they're processed in the correct order with a single debounce implementation.

**Key takeaways:**

- Each `usePacedMutations()` call = unique queue
- Each `createPacedMutations()` call = unique queue
- To share a queue: create one instance and import it everywhere you need it
- Shared queues ensure mutations from different places are ordered correctly

## Mutation Merging

When multiple mutations operate on the same item within a transaction, TanStack DB intelligently merges them to:
- **Reduce network traffic**: Fewer mutations sent to the server
- **Preserve user intent**: Final state matches what user expects
- **Maintain UI consistency**: Local state always reflects user actions

The merging behavior follows a truth table based on the mutation types:

| Existing → New      | Result    | Description                                       |
| ------------------- | --------- | ------------------------------------------------- |
| **insert + update** | `insert`  | Keeps insert type, merges changes, empty original |
| **insert + delete** | _removed_ | Mutations cancel each other out                   |
| **update + delete** | `delete`  | Delete dominates                                  |
| **update + update** | `update`  | Union changes, keep first original                |

> [!NOTE]
> Attempting to insert or delete the same item multiple times within a transaction will throw an error.

## Controlling Optimistic Behavior

By default, all mutations apply optimistic updates immediately to provide instant feedback. You can disable this behavior when you do not want a pending transaction to affect the local view. With `optimistic: false`, the view changes only when the collection receives new synced or local data; successful handler settlement does not publish the mutation by itself.

### When to Disable Optimistic Updates

Consider using `optimistic: false` when:

- **Complex server-side processing**: Operations that depend on server-side generation (e.g., cascading foreign keys, computed fields)
- **Validation requirements**: Operations where backend validation might reject the change
- **Confirmation workflows**: Deletes where data should remain visible until confirmed synced data removes it
- **Batch operations**: Large operations where optimistic rollback would be disruptive

### Behavior Differences

**`optimistic: true` (default)**:
- Immediately applies mutation to the local store
- Provides instant UI feedback
- Requires rollback if server rejects the mutation
- Best for simple, predictable operations

**`optimistic: false`**:
- Does not modify the local view while the handler is pending
- No optimistic view change to roll back
- The view updates only when the collection publishes new synced or local data
- Best for complex or validation-heavy operations

### Using Non-Optimistic Mutations

```typescript
// Critical deletion that needs confirmation
const handleDeleteAccount = () => {
  userCollection.delete(userId, { optimistic: false })
}

// Server-generated data
const handleCreateInvoice = () => {
  // Server generates invoice number, tax calculations, etc.
  invoiceCollection.insert(invoiceData, { optimistic: false })
}

// Mixed approach in same transaction
tx.mutate(() => {
  // Instant UI feedback for simple change
  todoCollection.update(todoId, (draft) => {
    draft.completed = true
  })

  // The transaction mutationFn must cause the confirmed data to sync.
  auditCollection.insert(auditRecord, { optimistic: false })
})
```

### Waiting for Handler Settlement

A common pattern with `optimistic: false` is to wait for the mutation handler to
complete before navigating or showing success feedback. If the feedback means
"confirmed and published by the server," configure the handler to wait for both
the confirmation and the relevant sync observation before returning:

```typescript
const handleCreatePost = async (postData) => {
  // Insert without optimistic updates
  const tx = postsCollection.insert(postData, { optimistic: false })

  try {
    // Wait for this transaction's handler to complete.
    await tx.isPersisted.promise

    // This is confirmed and published only if the handler awaited both.
    navigate(`/posts/${postData.id}`)
  } catch (error) {
    // Show error notification
    toast.error("Failed to create post: " + error.message)
  }
}
```

## Transaction States

Transactions progress through the following states during their lifecycle:

1. **`pending`**: Initial state when a transaction is created and optimistic mutations can be applied
2. **`persisting`**: The transaction's mutation handler is running
3. **`completed`**: The mutation handler returned successfully while the transaction was still `persisting`
4. **`failed`**: The transaction was rolled back or its mutation handler threw

These are local transaction states. `completed` means server-confirmed only if
the mutation handler waited for the relevant backend acknowledgement or sync
observation before returning.

### Monitoring Transaction State

```typescript
const tx = todoCollection.update(todoId, (draft) => {
  draft.completed = true
})

// Check current state
console.log(tx.state) // 'pending'

// Wait for specific states
await tx.isPersisted.promise
console.log(tx.state) // 'completed'; a rejection takes the transaction to 'failed'

// Handle errors
try {
  await tx.isPersisted.promise
  console.log("Success!")
} catch (error) {
  console.log("Failed:", error)
}
```

### State Transitions

The normal flow is: `pending` → `persisting` → `completed`

If an error occurs: `pending` → `persisting` → `failed`

Failed transactions automatically rollback their optimistic state.

### Retry Behavior

**Important:** TanStack DB does not automatically retry failed mutations. If a mutation fails (network error, server error, etc.), the transaction transitions to `failed` state and the optimistic state is rolled back. This is by design. Automatic retry logic varies significantly based on your use case (idempotency requirements, error types, backoff strategies, etc.).

To implement retry logic, wrap your API calls in your `mutationFn`:

```typescript
// Simple retry helper
async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3,
  delay = 1000
): Promise<T> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      if (attempt === maxRetries - 1) throw error
      await new Promise(resolve => setTimeout(resolve, delay * (attempt + 1)))
    }
  }
  throw new Error('Unreachable')
}

// Use in your collection
const todoCollection = createCollection({
  id: "todos",
  onUpdate: async ({ transaction }) => {
    const mutation = transaction.mutations[0]
    // Retry up to 3 times with increasing delay
    await withRetry(() =>
      api.todos.update(mutation.original.id, mutation.changes)
    )
  },
})
```

For more sophisticated retry strategies, consider using a library like [p-retry](https://github.com/sindresorhus/p-retry) which supports exponential backoff, custom retry conditions, and abort signals.

## Handling Temporary IDs

When inserting new items into collections where the server generates the final ID, you'll need to handle the transition from temporary to real IDs carefully to avoid UI issues and operation failures.

### The Problem

When you insert an item with a temporary ID, the optimistic object is eventually replaced by the synced object with its real server-generated ID. This can cause two issues:

1. **UI Flicker**: Your UI framework may unmount and remount components when the key changes from temporary to real ID
2. **Subsequent Operations**: Operations like delete may fail if they try to use the temporary ID before the real ID syncs back

```tsx
// Generate temporary ID (e.g., negative number)
const tempId = -(Math.floor(Math.random() * 1000000) + 1)

// Insert with temporary ID
todoCollection.insert({
  id: tempId,
  text: "New todo",
  completed: false
})

// Problem 1: UI may re-render when tempId is replaced with real ID
// Problem 2: Trying to delete before sync completes will use tempId
todoCollection.delete(tempId) // May 404 on backend
```

### Solution 1: Use Client-Generated UUIDs

If your backend supports client-generated IDs, use UUIDs to eliminate the temporary ID problem entirely:

```tsx
// Generate UUID on client
const id = crypto.randomUUID()

todoCollection.insert({
  id,
  text: "New todo",
  completed: false
})

// No flicker - the ID is stable
// Subsequent operations work immediately
todoCollection.delete(id) // Works with the same ID
```

This is the cleanest approach when your backend supports it, as the ID never changes.

### Solution 2: Wait for Authoritative Sync or Use Non-Optimistic Inserts

Configure the mutation handler to wait for the server response and the
authoritative row to sync before it returns. You can then await handler
settlement before enabling subsequent operations. `isPersisted.promise` does
not expose or translate the real ID; read it from the synced row or an
application-owned response mapping. With a non-optimistic insert, the pending
item stays out of the view until the collection publishes synced data.

```tsx
const handleCreateTodo = async (text: string) => {
  const tempId = -(Math.floor(Math.random() * 1000000) + 1)

  const tx = todoCollection.insert({
    id: tempId,
    text,
    completed: false
  })

  // This is an authoritative-sync gate only if onInsert waits for that sync.
  await tx.isPersisted.promise

  // Do not infer ID readiness from transaction state. Read the synced row or
  // application-owned mapping, then enable operations with that real ID.
}
```

### Solution 3: Maintain a View Key Mapping

To avoid UI flicker while keeping optimistic updates, maintain a separate mapping from IDs (both temporary and real) to stable view keys:

```tsx
// Create a mapping API
const idToViewKey = new Map<number | string, string>()

function getViewKey(id: number | string): string {
  if (!idToViewKey.has(id)) {
    idToViewKey.set(id, crypto.randomUUID())
  }
  return idToViewKey.get(id)!
}

function linkIds(tempId: number, realId: number) {
  const viewKey = getViewKey(tempId)
  idToViewKey.set(realId, viewKey)
}

// Configure collection to link IDs when real ID comes back
const todoCollection = createCollection({
  id: "todos",
  // ... other options
  onInsert: async ({ transaction }) => {
    const mutation = transaction.mutations[0]
    const tempId = mutation.modified.id

    // Create todo on server and get real ID back
    const response = await api.todos.create({
      text: mutation.modified.text,
      completed: mutation.modified.completed,
    })
    const realId = response.id

    // Link temp ID to same view key as real ID
    linkIds(tempId, realId)

    // Wait for sync back
    await todoCollection.utils.refetch()
  },
})

// When inserting with temp ID
const tempId = -Math.floor(Math.random() * 1000000) + 1
const viewKey = getViewKey(tempId) // Creates and stores mapping

todoCollection.insert({
  id: tempId,
  text: "New todo",
  completed: false
})

// Use view key for rendering
const TodoList = () => {
  const { data: todos } = useLiveQuery({
    query: (q) => q.from({ todo: todoCollection }),
  })

  return (
    <ul>
      {todos.map((todo) => (
        <li key={getViewKey(todo.id)}> {/* Stable key */}
          {todo.text}
        </li>
      ))}
    </ul>
  )
}
```

This pattern maintains a stable key throughout the temporary → real ID transition, preventing your UI framework from unmounting and remounting the component. The view key is stored outside the collection items, so you don't need to add extra fields to your data model.

### Best Practices

1. **Use UUIDs when possible**: Client-generated UUIDs eliminate the temporary ID problem
2. **Generate temporary IDs deterministically**: Use negative numbers or a specific pattern to distinguish temporary IDs from real ones
3. **Disable operations on temporary items**: Disable delete/update buttons until persistence completes
4. **Maintain view key mappings**: Create a mapping between IDs and stable view keys for rendering

> [!NOTE]
> There's an [open issue](https://github.com/TanStack/db/issues/19) to add better built-in support for temporary ID handling in TanStack DB. This would automate the view key pattern and make it easier to work with server-generated IDs.
