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 β backend persistence β sync back β confirmed state. 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, then persisted to your backend, and finally the optimistic state is replaced by the confirmed server state once it syncs back.
// 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
})
// 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:
With an instant inner loop of optimistic state, superseded in time by the slower outer loop of persisting to the server and syncing the updated server state back into the collection.
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):
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'] })
},
})
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):
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,
})
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:
TanStack DB provides different approaches to mutations, each suited to different use cases:
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.
// Direct state change
todoCollection.update(todoId, (draft) => {
draft.completed = true
draft.completedAt = new Date()
})
// Direct state change
todoCollection.update(todoId, (draft) => {
draft.completed = true
draft.completedAt = new Date()
})
Use collection-level mutations when:
You can use metadata to annotate these operations and customize behavior in your handlers:
// 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)
)
)
}
}
// 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)
)
)
}
}
For more complex scenarios, use createOptimisticAction to create intent-based mutations that capture specific user actions.
// 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)
// 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:
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:
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.
// 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
}
// 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:
How to sync changes back:
The mutation lifecycle follows a consistent pattern across all mutation types:
// 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 persists to backend
// Step 4: Handler waits for sync back
// Step 5: Optimistic state replaced by server state
// 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 persists to backend
// Step 4: Handler waits for sync back
// Step 5: Optimistic state replaced by server state
If the handler throws an error during persistence, the optimistic state is automatically rolled back.
Collections support three core write operations: insert, update, and delete. Each operation applies optimistic state immediately and triggers the corresponding operation handler.
Add new items to a collection:
// 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 }
)
// 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.
Modify existing items using an immutable draft pattern:
// 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"
}
)
// 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:
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.
Remove items from a collection:
// 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 })
// 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:
Returns: A Transaction object that you can use to track the mutation's lifecycle.
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.
All operation handlers receive an object with the following properties:
type OperationHandler = (params: {
transaction: Transaction
collection: Collection
}) => Promise<any> | any
type OperationHandler = (params: {
transaction: Transaction
collection: Collection
}) => Promise<any> | any
The transaction object contains:
Define handlers when creating a collection:
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)
)
)
},
})
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.
Different collection types have specific patterns for their handlers:
QueryCollection - automatically refetches after handler completes:
onUpdate: async ({ transaction }) => {
await Promise.all(
transaction.mutations.map((mutation) =>
api.todos.update(mutation.original.id, mutation.changes)
)
)
// Automatic refetch happens after handler completes
}
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:
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 }
}
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 }
}
You can define a single mutation function for your entire app:
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,
})
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,
})
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.
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"
})
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:
For comprehensive documentation on schema validation and transformations, see the Schemas guide.
For more complex mutation patterns, use createOptimisticAction to create custom actions with full control over the mutation lifecycle.
Create an action that combines mutation logic with persistence:
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} />
}
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} />
}
For better type safety and runtime validation, you can use schema validation libraries like Zod, Valibot, or others. Here's an example using Zod:
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} />
}
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:
Actions can mutate multiple collections:
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
},
})
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
},
})
The mutationFn receives additional parameters for advanced use cases:
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
},
})
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
},
})
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.
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()
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()
Manual transactions accept the following options:
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
})
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:
Manual transactions provide several methods:
// 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()
// 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()
Manual transactions excel at complex workflows:
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()
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()
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.
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().
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()
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()
You can mix local and server collections in the same transaction:
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()
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()
When to call acceptMutations matters for transaction semantics:
After API success (recommended for consistency):
mutationFn: async ({ transaction }) => {
await api.save(data) // API call first
localData.utils.acceptMutations(transaction) // Persist after success
}
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):
mutationFn: async ({ transaction }) => {
localData.utils.acceptMutations(transaction) // Persist first
await api.save(data) // Then API call
}
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.
Monitor transaction state changes:
const tx = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
await api.persist(transaction.mutations)
},
})
// Wait for transaction to complete
tx.isPersisted.promise.then(() => {
console.log("Transaction persisted!")
})
// Check current state
console.log(tx.state) // 'pending', 'persisting', 'completed', or 'failed'
const tx = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
await api.persist(transaction.mutations)
},
})
// Wait for transaction to complete
tx.isPersisted.promise.then(() => {
console.log("Transaction persisted!")
})
// Check current state
console.log(tx.state) // 'pending', 'persisting', 'completed', or 'failed'
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, paced mutations are ideal for scenarios like:
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.
| 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 |
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.
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>
)
}
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:
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.
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))}
/>
)
}
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:
The queue strategy creates a separate transaction for each mutation and processes them sequentially in order. Unlike debounce/throttle, every mutation is guaranteed to persist, making it ideal for workflows where you can't lose any operations.
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!)} />
}
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:
Use this guide to pick the right strategy for your use case:
Use debounceStrategy when:
Use throttleStrategy when:
Use queueStrategy when:
The usePacedMutations hook makes it easy to use paced mutations in React components:
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 persistence
try {
await tx.isPersisted.promise
console.log('Saved successfully!')
} catch (error) {
console.error('Save failed:', error)
}
}
return <button onClick={() => handleSave(42)}>Save</button>
}
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 persistence
try {
await tx.isPersisted.promise
console.log('Saved successfully!')
} 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:
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' } })
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' } })
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:
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)} />
}
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:
// 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 })} />
}
// 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:
When multiple mutations operate on the same item within a transaction, TanStack DB intelligently merges them to:
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.
By default, all mutations apply optimistic updates immediately to provide instant feedback. However, you can disable this behavior when you need to wait for server confirmation before applying changes locally.
Consider using optimistic: false when:
optimistic: true (default):
optimistic: false:
// 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
})
// Wait for server confirmation for complex change
auditCollection.insert(auditRecord, { optimistic: false })
})
// 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
})
// Wait for server confirmation for complex change
auditCollection.insert(auditRecord, { optimistic: false })
})
A common pattern with optimistic: false is to wait for the mutation to complete before navigating or showing success feedback:
const handleCreatePost = async (postData) => {
// Insert without optimistic updates
const tx = postsCollection.insert(postData, { optimistic: false })
try {
// Wait for write to server and sync back to complete
await tx.isPersisted.promise
// Server write and sync back were successful
navigate(`/posts/${postData.id}`)
} catch (error) {
// Show error notification
toast.error("Failed to create post: " + error.message)
}
}
// Works with updates and deletes too
const handleUpdateTodo = async (todoId, changes) => {
const tx = todoCollection.update(
todoId,
{ optimistic: false },
(draft) => Object.assign(draft, changes)
)
try {
await tx.isPersisted.promise
navigate("/todos")
} catch (error) {
toast.error("Failed to update todo: " + error.message)
}
}
const handleCreatePost = async (postData) => {
// Insert without optimistic updates
const tx = postsCollection.insert(postData, { optimistic: false })
try {
// Wait for write to server and sync back to complete
await tx.isPersisted.promise
// Server write and sync back were successful
navigate(`/posts/${postData.id}`)
} catch (error) {
// Show error notification
toast.error("Failed to create post: " + error.message)
}
}
// Works with updates and deletes too
const handleUpdateTodo = async (todoId, changes) => {
const tx = todoCollection.update(
todoId,
{ optimistic: false },
(draft) => Object.assign(draft, changes)
)
try {
await tx.isPersisted.promise
navigate("/todos")
} catch (error) {
toast.error("Failed to update todo: " + error.message)
}
}
Transactions progress through the following states during their lifecycle:
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' or 'failed'
// Handle errors
try {
await tx.isPersisted.promise
console.log("Success!")
} catch (error) {
console.log("Failed:", error)
}
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' or 'failed'
// Handle errors
try {
await tx.isPersisted.promise
console.log("Success!")
} catch (error) {
console.log("Failed:", error)
}
The normal flow is: pending β persisting β completed
If an error occurs: pending β persisting β failed
Failed transactions automatically rollback their optimistic state.
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.
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:
// 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
// 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
If your backend supports client-generated IDs, use UUIDs to eliminate the temporary ID problem entirely:
// 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
// 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.
Wait for the mutation to persist before allowing subsequent operations, or use non-optimistic inserts to avoid showing the item until the real ID is available:
const handleCreateTodo = async (text: string) => {
const tempId = -Math.floor(Math.random() * 1000000) + 1
const tx = todoCollection.insert({
id: tempId,
text,
completed: false
})
// Wait for persistence to complete
await tx.isPersisted.promise
// Now we have the real ID from the server
// Subsequent operations will use the real ID
}
// Disable delete buttons until persisted
const TodoItem = ({ todo, isPersisted }: { todo: Todo, isPersisted: boolean }) => {
return (
<div>
{todo.text}
<button
onClick={() => todoCollection.delete(todo.id)}
disabled={!isPersisted}
>
Delete
</button>
</div>
)
}
const handleCreateTodo = async (text: string) => {
const tempId = -Math.floor(Math.random() * 1000000) + 1
const tx = todoCollection.insert({
id: tempId,
text,
completed: false
})
// Wait for persistence to complete
await tx.isPersisted.promise
// Now we have the real ID from the server
// Subsequent operations will use the real ID
}
// Disable delete buttons until persisted
const TodoItem = ({ todo, isPersisted }: { todo: Todo, isPersisted: boolean }) => {
return (
<div>
{todo.text}
<button
onClick={() => todoCollection.delete(todo.id)}
disabled={!isPersisted}
>
Delete
</button>
</div>
)
}
To avoid UI flicker while keeping optimistic updates, maintain a separate mapping from IDs (both temporary and real) to stable view keys:
// 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((q) =>
q.from({ todo: todoCollection })
)
return (
<ul>
{todos.map((todo) => (
<li key={getViewKey(todo.id)}> {/* Stable key */}
{todo.text}
</li>
))}
</ul>
)
}
// 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((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.
Note
There's an open issue 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.
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.
