From b8b4e130a7a14ab2149cd01a470aa71a2e63d21e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 30 Sep 2025 17:19:51 -0600 Subject: [PATCH 01/15] Add WIP version of mutations guide --- docs/guides/mutations.md | 1032 ++++++++++++++++++++++++++++++++++++++ docs/overview.md | 397 +-------------- 2 files changed, 1043 insertions(+), 386 deletions(-) create mode 100644 docs/guides/mutations.md diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md new file mode 100644 index 000000000..5d1ce1211 --- /dev/null +++ b/docs/guides/mutations.md @@ -0,0 +1,1032 @@ +--- +title: Mutations +id: mutations +--- + +# TanStack DB Mutations + +TanStack DB provides a powerful mutation system that enables optimistic updates with automatic state management. Mutations support instant local updates that are superseded by server writes, creating a responsive user experience while maintaining data consistency. + +The mutation system is built around a pattern of **optimistic mutation → backend persistence → sync back → confirmed state**. 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. + +```tsx +// Define a collection with a mutation handler +const todoCollection = createCollection({ + id: "todos", + onUpdate: async ({ transaction }) => { + const { original, modified } = transaction.mutations[0] + await api.todos.update(original.id, modified) + }, +}) + +// 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 by the slower outer loop of server persistence. + +## 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) +- [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, metadata }) => { + if (metadata?.intent === 'complete') { + await api.todos.complete(transaction.mutations[0].original.id) + } else { + await api.todos.update(transaction.mutations[0].original.id, transaction.mutations[0].changes) + } +} +``` + +### Intent-Based Mutations with Custom Actions + +For more complex scenarios, use `createOptimisticAction` or `createTransaction` to create **intent-based mutations** that capture specific user actions. + +```tsx +// Intent: "like this post" +const likePost = createOptimisticAction({ + 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.refetch() + }, +}) +``` + +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 +- **`createTransaction`**: Fully custom transactions, delayed commits, multi-step workflows + +## 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 (`onInsert`, `onUpdate`, or `onDelete`) is called to persist the change +3. **Backend persistence**: Your handler persists the data to your backend +4. **Sync back**: The handler ensures server writes have synced back to the collection +5. **Optimistic state dropped**: Once synced, the optimistic state is replaced by the confirmed server state + +```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 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. + +This is similar to how the pioneers crossed the plains—they moved forward with faith (optimistic updates), trusting that their preparations (backend persistence) would sustain them, and eventually they reached their destination (confirmed state) after a challenging journey. + +## 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 Immer under the hood to capture changes as immutable updates. You must not reassign the draft parameter itself—only mutate its properties. + +### 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 + metadata?: Record +}) => Promise | void +``` + +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 + +### Defining Operation Handlers + +Define handlers when creating a collection: + +```typescript +const todoCollection = createCollection({ + id: "todos", + // ... other options + + onInsert: async ({ transaction }) => { + const { modified: newTodo } = transaction.mutations[0] + await api.todos.create(newTodo) + // Wait for sync back before returning + await transaction.mutations[0].collection.refetch() + }, + + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0] + await api.todos.update(original.id, changes) + // Wait for sync back before returning + await transaction.mutations[0].collection.refetch() + }, + + onDelete: async ({ transaction }) => { + const { original } = transaction.mutations[0] + await api.todos.delete(original.id) + // Wait for sync back before returning + await transaction.mutations[0].collection.refetch() + }, +}) +``` + +> [!IMPORTANT] +> Operation handlers must ensure server writes have synced back before returning. The optimistic state is dropped when the handler returns, so failing to wait for sync will cause a flash of missing data. + +### Using Collection-Specific Utils + +Different collection types provide utilities to help wait for sync: + +**QueryCollection**: +```typescript +onUpdate: async ({ transaction }) => { + const { collection, original, changes } = transaction.mutations[0] + await api.todos.update(original.id, changes) + await collection.refetch() // TanStack Query refetch +} +``` + +**ElectricCollection**: +```typescript +onUpdate: async ({ transaction }) => { + const { collection, original, changes } = transaction.mutations[0] + const response = await api.todos.update(original.id, changes) + // Wait for Electric to sync the transaction + await collection.utils.awaitTxId(response.txid) +} +``` + +### 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}`) + } + + // Wait for sync back before returning + const collection = transaction.mutations[0].collection + await collection.refetch() +} + +// Use in collections +const todoCollection = createCollection({ + id: "todos", + onInsert: mutationFn, + onUpdate: mutationFn, + onDelete: mutationFn, +}) +``` + +## 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({ + 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 + + ) +} +``` + +### 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() + +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 tempId = transaction.mutations[0].modified.id + + // Create todo on server and get real ID back + const response = await api.todos.create({ + text: transaction.mutations[0].modified.text, + completed: transaction.mutations[0].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.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 ( +
    + {todos.map((todo) => ( +
  • {/* Stable key */} + {todo.text} +
  • + ))} +
+ ) +} +``` + +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. diff --git a/docs/overview.md b/docs/overview.md index 1e180f60f..ae34e5a9d 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -105,33 +105,11 @@ todoCollection.update(todo.id, (draft) => { }) ``` -Rather than mutating the collection data directly, the collection internally treats its synced/loaded data as immutable and maintains a separate set of local mutations as optimistic state. When live queries read from the collection, they see a local view that overlays the local optimistic mutations on-top-of the immutable synced data. +The collection maintains optimistic state separately from synced data. When live queries read from the collection, they see a local view that overlays the optimistic mutations on top of the immutable synced data. -The optimistic state is held until the `onUpdate` (in this case) handler resolves - at which point the data is persisted to the server and synced back to the local collection. +The optimistic state is held until the handler resolves, at which point the data is persisted to the server and synced back. If the handler throws an error, the optimistic state is rolled back. -If the handler throws an error, the optimistic state is rolled back. - -### Explicit transactions - -Mutations are based on a `Transaction` primitive. - -For simple state changes, directly mutating the collection and persisting with the operator handlers is enough. - -But for more complex use cases, you can directly create custom actions with `createOptimisticAction` or custom transactions with `createTransaction`. This lets you do things such as do transactions with multiple mutations across multiple collections, do chained transactions w/ intermediate rollbacks, etc. - -For example, in the following code, the mutationFn first sends the write to the server using `await api.todos.update(updatedTodo)` and then calls `await collection.refetch()` to trigger a re-fetch of the collection contents using TanStack Query. When this second await resolves, the collection is up-to-date with the latest changes and the optimistic state is safely discarded. - -```ts -const updateTodo = createOptimisticAction<{ id: string }>({ - onMutate, - mutationFn: async ({ transaction }) => { - const { collection, modified: updatedTodo } = transaction.mutations[0] - - await api.todos.update(updatedTodo) - await collection.refetch() - }, -}) -``` +For more complex mutations, you can create custom actions with `createOptimisticAction` or custom transactions with `createTransaction`. See the [Mutations guide](../guides/mutations.md) for details. ### Uni-directional data flow @@ -554,369 +532,16 @@ See the [Live Queries](../guides/live-queries.md) documentation for more details ### Transactional mutators -Transactional mutators allow you to batch and stage local changes across collections with: - -- immediate application of local optimistic updates -- flexible mutationFns to handle writes, with automatic rollbacks and management of optimistic state - -#### `mutationFn` - -Mutators are created with a `mutationFn`. You can define a single, generic `mutationFn` for your whole app. Or you can define collection or mutation specific functions. - -The `mutationFn` is responsible for handling the local changes and processing them, usually to send them to a server or database to be stored. - -**Important:** Inside your `mutationFn`, you must ensure that your server writes have synced back before you return, as the optimistic state is dropped when you return from the mutation function. You generally use collection-specific helpers to do this, such as Query's `utils.refetch()`, direct write APIs, or Electric's `utils.awaitTxId()`. - -For example: - -```tsx -import type { MutationFn } from "@tanstack/react-db" - -const mutationFn: MutationFn = async ({ transaction }) => { - const response = await api.todos.create(transaction.mutations) - - if (!response.ok) { - // Throwing an error will rollback the optimistic state. - throw new Error(`HTTP Error: ${response.status}`) - } - - const result = await response.json() - - // Wait for the transaction to be synced back from the server - // before discarding the optimistic state. - const collection: Collection = transaction.mutations[0].collection - await collection.refetch() -} -``` - -#### `createOptimisticAction` - -Use `createOptimisticAction` with your `mutationFn` and `onMutate` functions to create an action that you can use to mutate data in your components in fully custom ways: - -```tsx -import { createOptimisticAction } from "@tanstack/react-db" - -// Create the `addTodo` action, passing in your `mutationFn` and `onMutate`. -const addTodo = createOptimisticAction({ - onMutate: (text) => { - // Instantly applies the local optimistic state. - todoCollection.insert({ - id: uuid(), - text, - completed: false, - }) - }, - mutationFn: async (text, params) => { - // Persist the todo to your backend - const response = await fetch("/api/todos", { - method: "POST", - body: JSON.stringify({ text, completed: false }), - }) - const result = await response.json() - - // IMPORTANT: Ensure server writes have synced back before returning - // This ensures the optimistic state can be safely discarded - await todoCollection.utils.refetch() - - return result - }, -}) - -const Todo = () => { - const handleClick = () => { - // Triggers the onMutate and then the mutationFn - addTodo("🔥 Make app faster") - } - - return