))}
diff --git a/packages/db/src/serialized-mutations.ts b/packages/db/src/serialized-mutations.ts
index e74bb4f14..b91152c3a 100644
--- a/packages/db/src/serialized-mutations.ts
+++ b/packages/db/src/serialized-mutations.ts
@@ -72,101 +72,68 @@ export function createSerializedMutations<
config: SerializedMutationsConfig
): {
mutate: (callback: () => void) => Transaction
- cleanup: () => void
} {
const { strategy, ...transactionConfig } = config
- // Track pending transactions that haven't been committed yet
- const pendingTransactions = new Set>()
- // Track the currently executing transaction (being committed)
- let executingTransaction: Transaction | null = null
+ // The currently active transaction (pending, not yet persisting)
+ let activeTransaction: Transaction | null = null
- /**
- * Executes a mutation callback and returns the transaction.
- * The strategy controls when the transaction is actually committed.
- */
- function mutate(callback: () => void): Transaction {
- // Rollback all pending transactions from previous mutate() calls
- // This handles cases where the strategy dropped the callback (e.g. trailing: false)
- // and the previous transaction never got committed
- for (const pendingTx of pendingTransactions) {
- pendingTx.rollback()
+ // Commit callback that the strategy will call when it's time to persist
+ const commitCallback = () => {
+ if (!activeTransaction) {
+ throw new Error(
+ `Strategy callback called but no active transaction exists. This indicates a bug in the strategy implementation.`
+ )
}
- pendingTransactions.clear()
- // Create transaction with autoCommit disabled
- // The strategy will control when commit() is called
- const transaction = createTransaction({
- ...transactionConfig,
- autoCommit: false,
- })
-
- // Execute the mutation callback to populate the transaction
- transaction.mutate(callback)
-
- // Add to pending set
- pendingTransactions.add(transaction)
+ if (activeTransaction.state !== `pending`) {
+ throw new Error(
+ `Strategy callback called but active transaction is in state "${activeTransaction.state}". Expected "pending".`
+ )
+ }
- // Use the strategy to control when to commit
- strategy.execute(() => {
- // Remove from pending and mark as executing
- // Note: There should only be one pending transaction at this point
- // since we clear all previous ones at the start of each mutate() call
- pendingTransactions.delete(transaction)
- executingTransaction = transaction
+ const txToCommit = activeTransaction
- // Commit the transaction according to the strategy's timing
- transaction
- .commit()
- .then(() => {
- if (executingTransaction === transaction) {
- executingTransaction = null
- }
- })
- .catch(() => {
- // Errors are handled via transaction.isPersisted.promise
- // This catch prevents unhandled promise rejections
- if (executingTransaction === transaction) {
- executingTransaction = null
- }
- })
+ // Clear active transaction reference before committing
+ activeTransaction = null
- return transaction
+ // Commit the transaction
+ txToCommit.commit().catch(() => {
+ // Errors are handled via transaction.isPersisted.promise
+ // This catch prevents unhandled promise rejections
})
- return transaction
+ return txToCommit
}
/**
- * Cleanup strategy resources and rollback any pending transactions
- * Should be called when the serialized mutations manager is no longer needed
+ * Executes a mutation callback. Creates a new transaction if none is active,
+ * or adds to the existing active transaction. The strategy controls when
+ * the transaction is actually committed.
*/
- function cleanup() {
- // Cancel the strategy timer/queue
- strategy.cleanup()
-
- // Rollback all pending transactions
- for (const tx of pendingTransactions) {
- tx.rollback()
+ function mutate(callback: () => void): Transaction {
+ // Create a new transaction if we don't have an active one
+ if (!activeTransaction || activeTransaction.state !== `pending`) {
+ activeTransaction = createTransaction({
+ ...transactionConfig,
+ autoCommit: false,
+ })
}
- pendingTransactions.clear()
- // Rollback executing transaction if any, but only if it's not already completed
- if (executingTransaction) {
- // Check if transaction is still in a state that can be rolled back
- // Avoid throwing if the transaction just finished committing
- if (
- executingTransaction.state === `pending` ||
- executingTransaction.state === `persisting`
- ) {
- executingTransaction.rollback()
- }
- executingTransaction = null
- }
+ // Execute the mutation callback to add mutations to the active transaction
+ activeTransaction.mutate(callback)
+
+ // Save reference before calling strategy.execute, as some strategies (like queue)
+ // might call commitCallback synchronously, which sets activeTransaction = null
+ const txToReturn = activeTransaction
+
+ // Tell the strategy about this mutation (for debouncing, this resets the timer)
+ strategy.execute(commitCallback)
+
+ return txToReturn
}
return {
mutate,
- cleanup,
}
}
diff --git a/packages/react-db/src/useSerializedMutations.ts b/packages/react-db/src/useSerializedMutations.ts
index 8df20976d..9ece6767d 100644
--- a/packages/react-db/src/useSerializedMutations.ts
+++ b/packages/react-db/src/useSerializedMutations.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo } from "react"
+import { useCallback, useMemo } from "react"
import { createSerializedMutations } from "@tanstack/db"
import type { SerializedMutationsConfig, Transaction } from "@tanstack/db"
@@ -98,17 +98,12 @@ export function useSerializedMutations<
config: SerializedMutationsConfig
): (callback: () => void) => Transaction {
// Create serialized mutations instance with proper dependency tracking
- const { mutate, cleanup } = useMemo(() => {
+ const { mutate } = useMemo(() => {
return createSerializedMutations(config)
// Include all config properties in dependencies
// Strategy changes will recreate the instance
}, [config.mutationFn, config.metadata, config.strategy, config.id])
- // Cleanup on unmount or when dependencies change
- useEffect(() => {
- return () => cleanup()
- }, [cleanup])
-
// Return stable mutate callback
const stableMutate = useCallback(mutate, [mutate])
diff --git a/packages/react-db/tests/useSerializedMutations.test.tsx b/packages/react-db/tests/useSerializedMutations.test.tsx
new file mode 100644
index 000000000..c73444cdd
--- /dev/null
+++ b/packages/react-db/tests/useSerializedMutations.test.tsx
@@ -0,0 +1,154 @@
+import { describe, expect, it, vi } from "vitest"
+import { act, renderHook } from "@testing-library/react"
+import { createCollection, debounceStrategy } from "@tanstack/db"
+import { useSerializedMutations } from "../src/useSerializedMutations"
+import { mockSyncCollectionOptionsNoInitialState } from "../../db/tests/utils"
+
+type Item = {
+ id: number
+ value: number
+}
+
+describe(`useSerializedMutations with debounce strategy`, () => {
+ it(`should batch multiple rapid mutations into a single transaction`, async () => {
+ const mutationFn = vi.fn(async () => {})
+
+ const { result } = renderHook(() =>
+ useSerializedMutations({
+ mutationFn,
+ strategy: debounceStrategy({ wait: 50 }),
+ })
+ )
+
+ const collection = createCollection(
+ mockSyncCollectionOptionsNoInitialState({
+ id: `test`,
+ getKey: (item) => item.id,
+ })
+ )
+
+ // Setup collection
+ const preloadPromise = collection.preload()
+ collection.utils.begin()
+ collection.utils.commit()
+ collection.utils.markReady()
+ await preloadPromise
+
+ let tx1, tx2, tx3
+
+ // Trigger three rapid mutations (all within 50ms debounce window)
+ act(() => {
+ tx1 = result.current(() => {
+ collection.insert({ id: 1, value: 1 })
+ })
+ })
+
+ act(() => {
+ tx2 = result.current(() => {
+ collection.update(1, (draft) => {
+ draft.value = 2
+ })
+ })
+ })
+
+ act(() => {
+ tx3 = result.current(() => {
+ collection.update(1, (draft) => {
+ draft.value = 3
+ })
+ })
+ })
+
+ // All three calls should return the SAME transaction object
+ expect(tx1).toBe(tx2)
+ expect(tx2).toBe(tx3)
+
+ // Mutations get auto-merged (insert + updates on same key = single insert with final value)
+ expect(tx1.mutations).toHaveLength(1)
+ expect(tx1.mutations[0]).toMatchObject({
+ type: `insert`,
+ changes: { id: 1, value: 3 }, // Final merged value
+ })
+
+ // mutationFn should NOT have been called yet (still debouncing)
+ expect(mutationFn).not.toHaveBeenCalled()
+
+ // Wait for debounce period
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Now mutationFn should have been called ONCE with the merged mutation
+ expect(mutationFn).toHaveBeenCalledTimes(1)
+ expect(mutationFn).toHaveBeenCalledWith({
+ transaction: expect.objectContaining({
+ mutations: [
+ expect.objectContaining({
+ type: `insert`,
+ changes: { id: 1, value: 3 },
+ }),
+ ],
+ }),
+ })
+
+ // Transaction should be completed
+ expect(tx1.state).toBe(`completed`)
+ })
+
+ it(`should reset debounce timer on each new mutation`, async () => {
+ const mutationFn = vi.fn(async () => {})
+
+ const { result } = renderHook(() =>
+ useSerializedMutations({
+ mutationFn,
+ strategy: debounceStrategy({ wait: 50 }),
+ })
+ )
+
+ const collection = createCollection(
+ mockSyncCollectionOptionsNoInitialState({
+ id: `test`,
+ getKey: (item) => item.id,
+ })
+ )
+
+ const preloadPromise = collection.preload()
+ collection.utils.begin()
+ collection.utils.commit()
+ collection.utils.markReady()
+ await preloadPromise
+
+ // First mutation at t=0
+ act(() => {
+ result.current(() => {
+ collection.insert({ id: 1, value: 1 })
+ })
+ })
+
+ // Wait 40ms (still within 50ms debounce window)
+ await new Promise((resolve) => setTimeout(resolve, 40))
+
+ // mutationFn should NOT have been called yet
+ expect(mutationFn).not.toHaveBeenCalled()
+
+ // Second mutation at t=40 (resets the timer)
+ act(() => {
+ result.current(() => {
+ collection.update(1, (draft) => {
+ draft.value = 2
+ })
+ })
+ })
+
+ // Wait another 40ms (t=80, but only 40ms since last mutation)
+ await new Promise((resolve) => setTimeout(resolve, 40))
+
+ // mutationFn still should NOT have been called (timer was reset)
+ expect(mutationFn).not.toHaveBeenCalled()
+
+ // Wait another 20ms (t=100, now 60ms since last mutation, past the 50ms debounce)
+ await new Promise((resolve) => setTimeout(resolve, 20))
+
+ // NOW mutationFn should have been called
+ expect(mutationFn).toHaveBeenCalledTimes(1)
+ expect(mutationFn.mock.calls[0][0].transaction.mutations).toHaveLength(1) // Merged to 1 mutation
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d229b3deb..98bd1b139 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -334,6 +334,9 @@ importers:
'@tanstack/react-db':
specifier: workspace:*
version: link:../../../packages/react-db
+ mitt:
+ specifier: ^3.0.1
+ version: 3.0.1
react:
specifier: ^18.3.1
version: 18.3.1
@@ -509,7 +512,7 @@ importers:
version: 0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)
drizzle-zod:
specifier: ^0.8.3
- version: 0.8.3(drizzle-orm@0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7))(zod@4.1.11)
+ version: 0.8.3(drizzle-orm@0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7))(zod@3.25.76)
express:
specifier: ^4.21.2
version: 4.21.2
@@ -13213,6 +13216,11 @@ snapshots:
pg: 8.16.3
postgres: 3.4.7
+ drizzle-zod@0.8.3(drizzle-orm@0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7))(zod@3.25.76):
+ dependencies:
+ drizzle-orm: 0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)
+ zod: 3.25.76
+
drizzle-zod@0.8.3(drizzle-orm@0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7))(zod@4.1.11):
dependencies:
drizzle-orm: 0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)
From b050bf78fd2a1c31b9ffede8503ff44c02b53ce9 Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Tue, 21 Oct 2025 17:34:51 -0600
Subject: [PATCH 04/24] prettier
---
SERIALIZED_TRANSACTION_PLAN.md | 34 +++++++++++++------
.../serialized-mutations-demo/index.html | 2 +-
.../serialized-mutations-demo/src/index.css | 14 ++++----
feedback-3.md | 2 +-
4 files changed, 33 insertions(+), 19 deletions(-)
diff --git a/SERIALIZED_TRANSACTION_PLAN.md b/SERIALIZED_TRANSACTION_PLAN.md
index daa34c28b..1736c2afd 100644
--- a/SERIALIZED_TRANSACTION_PLAN.md
+++ b/SERIALIZED_TRANSACTION_PLAN.md
@@ -30,11 +30,13 @@ injectSerializedTransaction(config) // Uses Angular DI, follows injectLiveQuery
## Available Strategies (Based on Pacer Utilities)
### 1. **debounceStrategy({ wait, leading?, trailing? })**
+
- Uses Pacer's `Debouncer` class
- Waits for pause in activity before committing
- **Best for:** Search inputs, auto-save fields
### 2. **queueStrategy({ wait?, maxSize?, addItemsTo?, getItemsFrom? })**
+
- Uses Pacer's `Queuer` class
- Processes all transactions in order (FIFO/LIFO)
- FIFO: `{ addItemsTo: 'back', getItemsFrom: 'front' }`
@@ -42,11 +44,13 @@ injectSerializedTransaction(config) // Uses Angular DI, follows injectLiveQuery
- **Best for:** Sequential operations that must all complete
### 3. **throttleStrategy({ wait, leading?, trailing? })**
+
- Uses Pacer's `Throttler` class
- Evenly spaces transaction executions over time
- **Best for:** Sliders, scroll handlers, progress bars
### 4. **batchStrategy({ maxSize?, wait?, getShouldExecute? })**
+
- Uses Pacer's `Batcher` class
- Groups multiple mutations into batches
- Triggers on size or time threshold
@@ -121,7 +125,7 @@ cleanup()
```typescript
// packages/react-db
-import { debounceStrategy } from '@tanstack/react-db'
+import { debounceStrategy } from "@tanstack/react-db"
const mutate = useSerializedTransaction({
mutationFn: async ({ transaction }) => {
@@ -133,14 +137,16 @@ const mutate = useSerializedTransaction({
// Usage in component
const handleChange = async (value) => {
const tx = mutate(() => {
- collection.update(id, draft => { draft.value = value })
+ collection.update(id, (draft) => {
+ draft.value = value
+ })
})
// Optional: await persistence or handle errors
try {
await tx.isPersisted.promise
} catch (error) {
- console.error('Update failed:', error)
+ console.error("Update failed:", error)
}
}
```
@@ -179,8 +185,8 @@ const mutate = useSerializedTransaction({
},
strategy: queueStrategy({
wait: 200,
- addItemsTo: 'back',
- getItemsFrom: 'front'
+ addItemsTo: "back",
+ getItemsFrom: "front",
}),
})
```
@@ -188,6 +194,7 @@ const mutate = useSerializedTransaction({
## Implementation Steps
### Phase 1: Core Package (@tanstack/db)
+
1. Add `@tanstack/pacer` dependency to packages/db/package.json
2. Create strategy type definitions in strategies/types.ts
3. Implement strategy factories:
@@ -199,6 +206,7 @@ const mutate = useSerializedTransaction({
5. Export strategies + core function from packages/db/src/index.ts
### Phase 2: Framework Wrappers
+
6. **React** - Create `useSerializedTransaction` using useRef/useEffect/useCallback
7. **Solid** - Create `useSerializedTransaction` using createSignal/onCleanup (matches `useLiveQuery` pattern)
8. **Svelte** - Create `useSerializedTransaction` using Svelte stores
@@ -206,6 +214,7 @@ const mutate = useSerializedTransaction({
10. **Angular** - Create `injectSerializedTransaction` using inject/DestroyRef (matches `injectLiveQuery` pattern)
### Phase 3: Testing & Documentation
+
11. Write tests for core logic in packages/db
12. Write tests for each framework wrapper
13. Update README with examples
@@ -235,8 +244,8 @@ export function debounceStrategy(opts: {
export function queueStrategy(opts?: {
wait?: number
maxSize?: number
- addItemsTo?: 'front' | 'back'
- getItemsFrom?: 'front' | 'back'
+ addItemsTo?: "front" | "back"
+ getItemsFrom?: "front" | "back"
}): QueueStrategy
export function throttleStrategy(opts: {
@@ -257,6 +266,7 @@ export function batchStrategy(opts?: {
### Core createSerializedTransaction
The core function will:
+
1. Accept a strategy and mutationFn
2. Create a wrapper around `createTransaction` from existing code
3. Use the strategy's `execute()` method to control when transactions are committed
@@ -265,6 +275,7 @@ The core function will:
- `cleanup()` - cleans up strategy resources
**Important:** The `mutate()` function returns a `Transaction` object so callers can:
+
- Await `transaction.isPersisted.promise` to know when persistence completes
- Handle errors via try/catch or `.catch()`
- Access transaction state and metadata
@@ -272,14 +283,16 @@ The core function will:
### Strategy Factories
Each strategy factory returns an object with:
+
- `execute(fn)` - wraps the function with Pacer's utility
- `cleanup()` - cleans up the Pacer instance
Example for debounceStrategy:
+
```typescript
// NOTE: Import path needs validation - Pacer may export from main entry point
// Likely: import { Debouncer } from '@tanstack/pacer' or similar
-import { Debouncer } from '@tanstack/pacer' // TODO: Validate actual export path
+import { Debouncer } from "@tanstack/pacer" // TODO: Validate actual export path
export function debounceStrategy(opts: {
wait: number
@@ -289,13 +302,13 @@ export function debounceStrategy(opts: {
const debouncer = new Debouncer(opts)
return {
- _type: 'debounce' as const,
+ _type: "debounce" as const,
execute: (fn: () => void) => {
debouncer.execute(fn)
},
cleanup: () => {
debouncer.cancel()
- }
+ },
}
}
```
@@ -322,6 +335,7 @@ export function useSerializedTransaction(config) {
```
**Key fixes:**
+
- Include `config.strategy` in `useMemo` dependencies to handle strategy changes
- Properly cleanup when strategy changes (via useEffect cleanup)
- Return stable callback reference via `useCallback`
diff --git a/examples/react/serialized-mutations-demo/index.html b/examples/react/serialized-mutations-demo/index.html
index bafd2d2f4..43cfa1d79 100644
--- a/examples/react/serialized-mutations-demo/index.html
+++ b/examples/react/serialized-mutations-demo/index.html
@@ -1,4 +1,4 @@
-
+
diff --git a/examples/react/serialized-mutations-demo/src/index.css b/examples/react/serialized-mutations-demo/src/index.css
index 6dda2a4a0..d863dbaf4 100644
--- a/examples/react/serialized-mutations-demo/src/index.css
+++ b/examples/react/serialized-mutations-demo/src/index.css
@@ -5,9 +5,9 @@
}
body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
+ "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #f5f5f5;
@@ -15,8 +15,8 @@ body {
}
code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
+ font-family:
+ source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
@@ -50,7 +50,7 @@ h1 {
background: white;
border-radius: 8px;
padding: 20px;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.panel h2 {
@@ -266,7 +266,7 @@ button {
background: white;
border-radius: 8px;
padding: 16px;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
diff --git a/feedback-3.md b/feedback-3.md
index 5fce74143..3c5729294 100644
--- a/feedback-3.md
+++ b/feedback-3.md
@@ -1,4 +1,4 @@
-- High – Trailing strategies leave earlier transactions permanently pending. Each `mutate` call in `createSerializedTransaction` creates a fresh transaction and immediately returns it (`packages/db/src/serialized-transaction.ts:80-103`), but the debouncer/throttler only runs the *latest* scheduled callback (`packages/db/src/strategies/debounceStrategy.ts:31-45`, `packages/db/src/strategies/throttleStrategy.ts:45-58`). When a later call supersedes an earlier one, the earlier transaction never commits or rolls back, so `tx.isPersisted.promise` never resolves and the transaction stays in the global pending list. We need to either reuse a single transaction per manager or explicitly cancel/rollback superseded transactions whenever a strategy drops them.
+- High – Trailing strategies leave earlier transactions permanently pending. Each `mutate` call in `createSerializedTransaction` creates a fresh transaction and immediately returns it (`packages/db/src/serialized-transaction.ts:80-103`), but the debouncer/throttler only runs the _latest_ scheduled callback (`packages/db/src/strategies/debounceStrategy.ts:31-45`, `packages/db/src/strategies/throttleStrategy.ts:45-58`). When a later call supersedes an earlier one, the earlier transaction never commits or rolls back, so `tx.isPersisted.promise` never resolves and the transaction stays in the global pending list. We need to either reuse a single transaction per manager or explicitly cancel/rollback superseded transactions whenever a strategy drops them.
- High – `cleanup()` can leave optimistic mutations stuck in a pending transaction. If the consumer unmounts while a trailing debounce/throttle call is waiting, `cleanup()` only cancels the strategy (`packages/db/src/serialized-transaction.ts:109-111`). The transaction we already created remains `pending`, its optimistic changes stay applied, and `isPersisted.promise` never settles. `cleanup()` should flush or rollback any in-flight transaction before returning.
From c62164f28d2fdc17894cac5d78b1956a4ca16f88 Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Tue, 21 Oct 2025 17:39:08 -0600
Subject: [PATCH 05/24] test: add comprehensive tests for queue and throttle
strategies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Added test coverage for all three mutation strategies:
- Debounce: batching and timer reset (already passing)
- Queue: accumulation and sequential processing
- Throttle: leading/trailing edge execution
All 5 tests passing with 100% coverage on useSerializedMutations hook.
Also added changeset documenting the new serialized mutations feature.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.changeset/serialized-mutations.md | 44 ++++
.../tests/useSerializedMutations.test.tsx | 192 +++++++++++++++++-
2 files changed, 235 insertions(+), 1 deletion(-)
create mode 100644 .changeset/serialized-mutations.md
diff --git a/.changeset/serialized-mutations.md b/.changeset/serialized-mutations.md
new file mode 100644
index 000000000..8995c4c07
--- /dev/null
+++ b/.changeset/serialized-mutations.md
@@ -0,0 +1,44 @@
+---
+"@tanstack/db": minor
+"@tanstack/react-db": minor
+---
+
+Add serialized mutations with pluggable timing strategies
+
+Introduces a new serialized mutations system that enables optimistic mutations with pluggable timing strategies. This provides fine-grained control over when and how mutations are persisted to the backend.
+
+**Core Features:**
+
+- **Pluggable Strategy System**: Choose from debounce, queue, or throttle strategies to control mutation timing
+- **Auto-merging Mutations**: Multiple rapid mutations on the same item automatically merge for efficiency
+- **Transaction Management**: Full transaction lifecycle tracking (pending → persisting → completed/failed)
+- **React Hook**: `useSerializedMutations` for easy integration in React applications
+
+**Available Strategies:**
+
+- `debounceStrategy`: Wait for inactivity before persisting (ideal for auto-save, search-as-you-type)
+- `queueStrategy`: Process all mutations sequentially with FIFO/LIFO options (ideal for sequential workflows, rate-limited APIs)
+- `throttleStrategy`: Ensure minimum spacing between executions (ideal for analytics, progress updates)
+
+**Example Usage:**
+
+```ts
+import { useSerializedMutations, debounceStrategy } from "@tanstack/react-db"
+
+const mutate = useSerializedMutations({
+ mutationFn: async ({ transaction }) => {
+ await api.save(transaction.mutations)
+ },
+ strategy: debounceStrategy({ wait: 500 }),
+})
+
+// Trigger a mutation
+const tx = mutate(() => {
+ collection.update(id, (draft) => {
+ draft.value = newValue
+ })
+})
+
+// Optionally await persistence
+await tx.isPersisted.promise
+```
diff --git a/packages/react-db/tests/useSerializedMutations.test.tsx b/packages/react-db/tests/useSerializedMutations.test.tsx
index c73444cdd..2c9d741e1 100644
--- a/packages/react-db/tests/useSerializedMutations.test.tsx
+++ b/packages/react-db/tests/useSerializedMutations.test.tsx
@@ -1,6 +1,11 @@
import { describe, expect, it, vi } from "vitest"
import { act, renderHook } from "@testing-library/react"
-import { createCollection, debounceStrategy } from "@tanstack/db"
+import {
+ createCollection,
+ debounceStrategy,
+ queueStrategy,
+ throttleStrategy,
+} from "@tanstack/db"
import { useSerializedMutations } from "../src/useSerializedMutations"
import { mockSyncCollectionOptionsNoInitialState } from "../../db/tests/utils"
@@ -152,3 +157,188 @@ describe(`useSerializedMutations with debounce strategy`, () => {
expect(mutationFn.mock.calls[0][0].transaction.mutations).toHaveLength(1) // Merged to 1 mutation
})
})
+
+describe(`useSerializedMutations with queue strategy`, () => {
+ it(`should accumulate mutations then process sequentially`, async () => {
+ const mutationFn = vi.fn(async () => {
+ // Quick execution
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ })
+
+ const { result } = renderHook(() =>
+ useSerializedMutations({
+ mutationFn,
+ strategy: queueStrategy({ wait: 10 }),
+ })
+ )
+
+ const collection = createCollection(
+ mockSyncCollectionOptionsNoInitialState({
+ id: `test`,
+ getKey: (item) => item.id,
+ })
+ )
+
+ // Setup collection
+ const preloadPromise = collection.preload()
+ collection.utils.begin()
+ collection.utils.commit()
+ collection.utils.markReady()
+ await preloadPromise
+
+ let tx1
+
+ // Trigger rapid mutations within single act - they accumulate in one transaction
+ act(() => {
+ tx1 = result.current(() => {
+ collection.insert({ id: 1, value: 1 })
+ collection.insert({ id: 2, value: 2 })
+ collection.insert({ id: 3, value: 3 })
+ })
+ })
+
+ // Queue starts processing immediately
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ expect(mutationFn).toHaveBeenCalledTimes(1)
+
+ // Wait for transaction to complete
+ await tx1.isPersisted.promise
+ expect(tx1.state).toBe(`completed`)
+
+ // All 3 mutations should be in the same transaction
+ expect(tx1.mutations).toHaveLength(3)
+ })
+})
+
+describe(`useSerializedMutations with throttle strategy`, () => {
+ it(`should throttle mutations with leading and trailing execution`, async () => {
+ const mutationFn = vi.fn(async () => {})
+
+ const { result } = renderHook(() =>
+ useSerializedMutations({
+ mutationFn,
+ strategy: throttleStrategy({
+ wait: 100,
+ leading: true,
+ trailing: true,
+ }),
+ })
+ )
+
+ const collection = createCollection(
+ mockSyncCollectionOptionsNoInitialState({
+ id: `test`,
+ getKey: (item) => item.id,
+ })
+ )
+
+ // Setup collection
+ const preloadPromise = collection.preload()
+ collection.utils.begin()
+ collection.utils.commit()
+ collection.utils.markReady()
+ await preloadPromise
+
+ let tx1, tx2, tx3
+
+ // First mutation at t=0 (should execute immediately due to leading: true)
+ act(() => {
+ tx1 = result.current(() => {
+ collection.insert({ id: 1, value: 1 })
+ })
+ })
+
+ // Leading edge should execute immediately
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ expect(mutationFn).toHaveBeenCalledTimes(1)
+ expect(tx1.state).toBe(`completed`)
+
+ // Second mutation at t=20 (during throttle period, should batch)
+ act(() => {
+ tx2 = result.current(() => {
+ collection.insert({ id: 2, value: 2 })
+ })
+ })
+
+ // Third mutation at t=30 (during throttle period, should batch with second)
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ act(() => {
+ tx3 = result.current(() => {
+ collection.insert({ id: 3, value: 3 })
+ })
+ })
+
+ // tx2 and tx3 should be the same transaction (batched)
+ expect(tx2).toBe(tx3)
+
+ // Still only 1 call (waiting for throttle period to end)
+ expect(mutationFn).toHaveBeenCalledTimes(1)
+
+ // Wait for throttle period to complete (100ms from first mutation)
+ await new Promise((resolve) => setTimeout(resolve, 110))
+
+ // Trailing edge should have executed
+ expect(mutationFn).toHaveBeenCalledTimes(2)
+ expect(tx2.state).toBe(`completed`)
+ expect(tx3.state).toBe(`completed`)
+
+ // Verify the batched transaction has 2 inserts
+ expect(tx2.mutations).toHaveLength(2)
+ })
+
+ it(`should respect trailing: true with leading: false option`, async () => {
+ const mutationFn = vi.fn(async () => {})
+
+ const { result } = renderHook(() =>
+ useSerializedMutations({
+ mutationFn,
+ strategy: throttleStrategy({
+ wait: 50,
+ leading: false,
+ trailing: true,
+ }),
+ })
+ )
+
+ const collection = createCollection(
+ mockSyncCollectionOptionsNoInitialState({
+ id: `test`,
+ getKey: (item) => item.id,
+ })
+ )
+
+ const preloadPromise = collection.preload()
+ collection.utils.begin()
+ collection.utils.commit()
+ collection.utils.markReady()
+ await preloadPromise
+
+ let tx1
+
+ // First mutation should NOT execute immediately with leading: false
+ act(() => {
+ tx1 = result.current(() => {
+ collection.insert({ id: 1, value: 1 })
+ })
+ })
+
+ // Should not have been called yet
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ expect(mutationFn).not.toHaveBeenCalled()
+
+ // Add another mutation during throttle period to ensure trailing fires
+ act(() => {
+ result.current(() => {
+ collection.insert({ id: 2, value: 2 })
+ })
+ })
+
+ // Wait for throttle period to complete
+ await new Promise((resolve) => setTimeout(resolve, 70))
+
+ // Now trailing edge should have executed
+ expect(mutationFn).toHaveBeenCalledTimes(1)
+ await tx1.isPersisted.promise
+ expect(tx1.state).toBe(`completed`)
+ })
+})
From eb60264b06618e6192b725a4aa11b53580ceb16c Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Tue, 21 Oct 2025 18:38:22 -0600
Subject: [PATCH 06/24] fix: resolve TypeScript strict mode errors in
useSerializedMutations tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Added non-null assertions and proper type casting for test variables
to satisfy TypeScript's strict null checking. All 62 tests still passing
with 100% coverage on useSerializedMutations hook.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.../tests/useSerializedMutations.test.tsx | 29 ++++++++++---------
1 file changed, 16 insertions(+), 13 deletions(-)
diff --git a/packages/react-db/tests/useSerializedMutations.test.tsx b/packages/react-db/tests/useSerializedMutations.test.tsx
index 2c9d741e1..7f9ca9628 100644
--- a/packages/react-db/tests/useSerializedMutations.test.tsx
+++ b/packages/react-db/tests/useSerializedMutations.test.tsx
@@ -69,8 +69,8 @@ describe(`useSerializedMutations with debounce strategy`, () => {
expect(tx2).toBe(tx3)
// Mutations get auto-merged (insert + updates on same key = single insert with final value)
- expect(tx1.mutations).toHaveLength(1)
- expect(tx1.mutations[0]).toMatchObject({
+ expect(tx1!.mutations).toHaveLength(1)
+ expect(tx1!.mutations[0]).toMatchObject({
type: `insert`,
changes: { id: 1, value: 3 }, // Final merged value
})
@@ -95,7 +95,7 @@ describe(`useSerializedMutations with debounce strategy`, () => {
})
// Transaction should be completed
- expect(tx1.state).toBe(`completed`)
+ expect(tx1!.state).toBe(`completed`)
})
it(`should reset debounce timer on each new mutation`, async () => {
@@ -154,7 +154,10 @@ describe(`useSerializedMutations with debounce strategy`, () => {
// NOW mutationFn should have been called
expect(mutationFn).toHaveBeenCalledTimes(1)
- expect(mutationFn.mock.calls[0][0].transaction.mutations).toHaveLength(1) // Merged to 1 mutation
+ const firstCall = mutationFn.mock.calls[0] as unknown as [
+ { transaction: { mutations: Array } },
+ ]
+ expect(firstCall[0].transaction.mutations).toHaveLength(1) // Merged to 1 mutation
})
})
@@ -202,11 +205,11 @@ describe(`useSerializedMutations with queue strategy`, () => {
expect(mutationFn).toHaveBeenCalledTimes(1)
// Wait for transaction to complete
- await tx1.isPersisted.promise
- expect(tx1.state).toBe(`completed`)
+ await tx1!.isPersisted.promise
+ expect(tx1!.state).toBe(`completed`)
// All 3 mutations should be in the same transaction
- expect(tx1.mutations).toHaveLength(3)
+ expect(tx1!.mutations).toHaveLength(3)
})
})
@@ -251,7 +254,7 @@ describe(`useSerializedMutations with throttle strategy`, () => {
// Leading edge should execute immediately
await new Promise((resolve) => setTimeout(resolve, 10))
expect(mutationFn).toHaveBeenCalledTimes(1)
- expect(tx1.state).toBe(`completed`)
+ expect(tx1!.state).toBe(`completed`)
// Second mutation at t=20 (during throttle period, should batch)
act(() => {
@@ -279,11 +282,11 @@ describe(`useSerializedMutations with throttle strategy`, () => {
// Trailing edge should have executed
expect(mutationFn).toHaveBeenCalledTimes(2)
- expect(tx2.state).toBe(`completed`)
- expect(tx3.state).toBe(`completed`)
+ expect(tx2!.state).toBe(`completed`)
+ expect(tx3!.state).toBe(`completed`)
// Verify the batched transaction has 2 inserts
- expect(tx2.mutations).toHaveLength(2)
+ expect(tx2!.mutations).toHaveLength(2)
})
it(`should respect trailing: true with leading: false option`, async () => {
@@ -338,7 +341,7 @@ describe(`useSerializedMutations with throttle strategy`, () => {
// Now trailing edge should have executed
expect(mutationFn).toHaveBeenCalledTimes(1)
- await tx1.isPersisted.promise
- expect(tx1.state).toBe(`completed`)
+ await tx1!.isPersisted.promise
+ expect(tx1!.state).toBe(`completed`)
})
})
From ebd80fe4b04c246c4d16ce934fcc6f18c5c0e231 Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Tue, 21 Oct 2025 22:21:15 -0600
Subject: [PATCH 07/24] refactor: convert demo to slider-based interface with
300ms default
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Changed from button-based mutations to a slider interface that better
demonstrates the different strategies in action:
- Changed Item.value from string to number (was already being used as number)
- Reduced default wait time from 1000ms to 300ms for more responsive demo
- Replaced "Trigger Mutation" and "Trigger 5 Rapid Mutations" buttons with
a slider (0-100 range) that triggers mutations on every change
- Updated UI text to reference slider instead of buttons
- Changed mutation display from "value X-1 → X" to "value = X" since slider
sets absolute values rather than incrementing
The slider provides a more natural and vivid demonstration of how strategies
handle rapid mutations - users can drag it and see debounce wait for stops,
throttle sample during drags, and queue process all changes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.../serialized-mutations-demo/src/App.tsx | 58 +++++++++++--------
1 file changed, 33 insertions(+), 25 deletions(-)
diff --git a/examples/react/serialized-mutations-demo/src/App.tsx b/examples/react/serialized-mutations-demo/src/App.tsx
index bcb0891ec..c4d67947e 100644
--- a/examples/react/serialized-mutations-demo/src/App.tsx
+++ b/examples/react/serialized-mutations-demo/src/App.tsx
@@ -11,7 +11,7 @@ import type { PendingMutation, Transaction } from "@tanstack/react-db"
interface Item {
id: number
- value: string
+ value: number
timestamp: number
}
@@ -73,7 +73,7 @@ type StrategyType = `debounce` | `queue` | `throttle`
export function App() {
const [strategyType, setStrategyType] = useState(`debounce`)
- const [wait, setWait] = useState(1000)
+ const [wait, setWait] = useState(300)
const [leading, setLeading] = useState(false)
const [trailing, setTrailing] = useState(true)
@@ -153,11 +153,11 @@ export function App() {
strategy,
})
- // Trigger a mutation
- const triggerMutation = () => {
+ // Trigger a mutation with a specific value
+ const triggerMutation = (newValue: number) => {
const tx = mutate(() => {
itemCollection.update(1, (draft) => {
- draft.value += 1
+ draft.value = newValue
draft.timestamp = Date.now()
})
})
@@ -235,8 +235,8 @@ export function App() {
Serialized Mutations Demo
- Test different strategies and see how mutations are queued, executed,
- and persisted
+ Drag the slider to trigger mutations and see how different strategies
+ batch, queue, and persist changes
Date: Tue, 21 Oct 2025 23:08:46 -0600
Subject: [PATCH 09/24] fix(queue): capture transaction before clearing
activeTransaction
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Queue strategy now receives a closure that commits the captured transaction instead of calling commitCallback which expects activeTransaction to be set. This prevents "no active transaction exists" errors.
- Capture transaction before clearing activeTransaction for queue strategy
- Pass commit closure to queue that operates on captured transaction
- Remove "Reset to 0" button from demo
- All tests passing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.../serialized-mutations-demo/src/App.tsx | 18 -----------------
packages/db/src/serialized-mutations.ts | 20 +++++++++++++++----
2 files changed, 16 insertions(+), 22 deletions(-)
diff --git a/examples/react/serialized-mutations-demo/src/App.tsx b/examples/react/serialized-mutations-demo/src/App.tsx
index f4d5dd4c9..20877368d 100644
--- a/examples/react/serialized-mutations-demo/src/App.tsx
+++ b/examples/react/serialized-mutations-demo/src/App.tsx
@@ -223,18 +223,6 @@ export function App() {
})
}
- const clearHistory = () => {
- setTransactions([])
- // Reset the item value to 0
- itemCollection.update(1, (draft) => {
- draft.value = 0
- draft.timestamp = Date.now()
- })
- fakeServer.set(1, { id: 1, value: 0, timestamp: Date.now() })
- setOptimisticState(itemCollection.get(1))
- setSyncedState(fakeServer.get(1)!)
- }
-
const pending = transactions.filter((t) => t.state === `pending`)
const executing = transactions.filter((t) => t.state === `executing`)
const completed = transactions.filter((t) => t.state === `completed`)
@@ -348,12 +336,6 @@ export function App() {
-
-
- Reset to 0
-
-
-
Strategy Info:
diff --git a/packages/db/src/serialized-mutations.ts b/packages/db/src/serialized-mutations.ts
index b91152c3a..433c82b94 100644
--- a/packages/db/src/serialized-mutations.ts
+++ b/packages/db/src/serialized-mutations.ts
@@ -123,12 +123,24 @@ export function createSerializedMutations<
// Execute the mutation callback to add mutations to the active transaction
activeTransaction.mutate(callback)
- // Save reference before calling strategy.execute, as some strategies (like queue)
- // might call commitCallback synchronously, which sets activeTransaction = null
+ // Save reference before calling strategy.execute
const txToReturn = activeTransaction
- // Tell the strategy about this mutation (for debouncing, this resets the timer)
- strategy.execute(commitCallback)
+ // For queue strategy, pass a function that commits the captured transaction
+ // This prevents the error when commitCallback tries to access the cleared activeTransaction
+ if (strategy._type === `queue`) {
+ const capturedTx = activeTransaction
+ activeTransaction = null // Clear so next mutation creates a new transaction
+ strategy.execute(() => {
+ capturedTx.commit().catch(() => {
+ // Errors are handled via transaction.isPersisted.promise
+ })
+ return capturedTx
+ })
+ } else {
+ // For debounce/throttle, use commitCallback which manages activeTransaction
+ strategy.execute(commitCallback)
+ }
return txToReturn
}
From 969d532a2c8f937b6596eb8fc7120de8890f2227 Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Tue, 21 Oct 2025 23:18:37 -0600
Subject: [PATCH 10/24] fix(queue): explicitly default to FIFO processing order
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Set explicit defaults for addItemsTo='back' and getItemsFrom='front' to ensure queue strategy processes transactions in FIFO order (oldest first).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
examples/react/serialized-mutations-demo/src/App.tsx | 4 ++--
packages/db/src/strategies/queueStrategy.ts | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/examples/react/serialized-mutations-demo/src/App.tsx b/examples/react/serialized-mutations-demo/src/App.tsx
index 20877368d..826491c33 100644
--- a/examples/react/serialized-mutations-demo/src/App.tsx
+++ b/examples/react/serialized-mutations-demo/src/App.tsx
@@ -350,8 +350,8 @@ export function App() {
)}
{strategyType === `queue` && (
- Queue: Processes all mutations sequentially
- with {wait}ms between each.
+ Queue: Processes mutations sequentially in
+ order received (FIFO) with {wait}ms between each.
)}
{strategyType === `throttle` && (
diff --git a/packages/db/src/strategies/queueStrategy.ts b/packages/db/src/strategies/queueStrategy.ts
index 119d6662a..f6be6a63f 100644
--- a/packages/db/src/strategies/queueStrategy.ts
+++ b/packages/db/src/strategies/queueStrategy.ts
@@ -48,8 +48,8 @@ export function queueStrategy(options?: QueueStrategyOptions): QueueStrategy {
concurrency: 1, // Process one at a time to ensure serialization
wait: options?.wait,
maxSize: options?.maxSize,
- addItemsTo: options?.addItemsTo,
- getItemsFrom: options?.getItemsFrom,
+ addItemsTo: options?.addItemsTo ?? `back`, // Default FIFO: add to back
+ getItemsFrom: options?.getItemsFrom ?? `front`, // Default FIFO: get from front
started: true, // Start processing immediately
})
From 051e1385692b3da139b37f2c0bcb8c8b4c5c3473 Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Tue, 21 Oct 2025 23:24:59 -0600
Subject: [PATCH 11/24] docs: clarify queue strategy creates separate
transactions with configurable order
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Update changeset to reflect that queue strategy creates separate transactions per mutation and defaults to FIFO (but is configurable).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.changeset/serialized-mutations.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.changeset/serialized-mutations.md b/.changeset/serialized-mutations.md
index 8995c4c07..56dce9cfa 100644
--- a/.changeset/serialized-mutations.md
+++ b/.changeset/serialized-mutations.md
@@ -17,7 +17,7 @@ Introduces a new serialized mutations system that enables optimistic mutations w
**Available Strategies:**
- `debounceStrategy`: Wait for inactivity before persisting (ideal for auto-save, search-as-you-type)
-- `queueStrategy`: Process all mutations sequentially with FIFO/LIFO options (ideal for sequential workflows, rate-limited APIs)
+- `queueStrategy`: Process each mutation as a separate transaction sequentially (defaults to FIFO, configurable to LIFO) (ideal for sequential workflows, rate-limited APIs)
- `throttleStrategy`: Ensure minimum spacing between executions (ideal for analytics, progress updates)
**Example Usage:**
From 066e79a2e17a70ffe4e5928a6e1481db8a2d6288 Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Wed, 22 Oct 2025 10:41:08 -0600
Subject: [PATCH 12/24] refactor: rename "Serialized Mutations" to "Paced
Mutations"
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rename the feature from "Serialized Mutations" to "Paced Mutations" to better reflect its purpose of controlling mutation timing rather than serialization. This includes:
- Renamed core functions: createSerializedMutations → createPacedMutations
- Renamed React hook: useSerializedMutations → usePacedMutations
- Renamed types: SerializedMutationsConfig → PacedMutationsConfig
- Updated all file names, imports, exports, and documentation
- Updated demo app title and examples
- Updated changeset
All tests pass and the demo app builds successfully.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
...alized-mutations.md => paced-mutations.md} | 10 ++++-----
.../index.html | 2 +-
.../package.json | 2 +-
.../src/App.tsx | 8 +++----
.../src/index.css | 0
.../src/main.tsx | 0
.../tsconfig.json | 0
.../vite.config.ts | 0
packages/db/src/index.ts | 2 +-
...alized-mutations.ts => paced-mutations.ts} | 14 ++++++------
packages/react-db/src/index.ts | 2 +-
...lizedMutations.ts => usePacedMutations.ts} | 22 +++++++++----------
...ns.test.tsx => usePacedMutations.test.tsx} | 18 +++++++--------
13 files changed, 39 insertions(+), 41 deletions(-)
rename .changeset/{serialized-mutations.md => paced-mutations.md} (71%)
rename examples/react/{serialized-mutations-demo => paced-mutations-demo}/index.html (85%)
rename examples/react/{serialized-mutations-demo => paced-mutations-demo}/package.json (89%)
rename examples/react/{serialized-mutations-demo => paced-mutations-demo}/src/App.tsx (98%)
rename examples/react/{serialized-mutations-demo => paced-mutations-demo}/src/index.css (100%)
rename examples/react/{serialized-mutations-demo => paced-mutations-demo}/src/main.tsx (100%)
rename examples/react/{serialized-mutations-demo => paced-mutations-demo}/tsconfig.json (100%)
rename examples/react/{serialized-mutations-demo => paced-mutations-demo}/vite.config.ts (100%)
rename packages/db/src/{serialized-mutations.ts => paced-mutations.ts} (92%)
rename packages/react-db/src/{useSerializedMutations.ts => usePacedMutations.ts} (82%)
rename packages/react-db/tests/{useSerializedMutations.test.tsx => usePacedMutations.test.tsx} (95%)
diff --git a/.changeset/serialized-mutations.md b/.changeset/paced-mutations.md
similarity index 71%
rename from .changeset/serialized-mutations.md
rename to .changeset/paced-mutations.md
index 56dce9cfa..84f207b0d 100644
--- a/.changeset/serialized-mutations.md
+++ b/.changeset/paced-mutations.md
@@ -3,16 +3,16 @@
"@tanstack/react-db": minor
---
-Add serialized mutations with pluggable timing strategies
+Add paced mutations with pluggable timing strategies
-Introduces a new serialized mutations system that enables optimistic mutations with pluggable timing strategies. This provides fine-grained control over when and how mutations are persisted to the backend.
+Introduces a new paced mutations system that enables optimistic mutations with pluggable timing strategies. This provides fine-grained control over when and how mutations are persisted to the backend.
**Core Features:**
- **Pluggable Strategy System**: Choose from debounce, queue, or throttle strategies to control mutation timing
- **Auto-merging Mutations**: Multiple rapid mutations on the same item automatically merge for efficiency
- **Transaction Management**: Full transaction lifecycle tracking (pending → persisting → completed/failed)
-- **React Hook**: `useSerializedMutations` for easy integration in React applications
+- **React Hook**: `usePacedMutations` for easy integration in React applications
**Available Strategies:**
@@ -23,9 +23,9 @@ Introduces a new serialized mutations system that enables optimistic mutations w
**Example Usage:**
```ts
-import { useSerializedMutations, debounceStrategy } from "@tanstack/react-db"
+import { usePacedMutations, debounceStrategy } from "@tanstack/react-db"
-const mutate = useSerializedMutations({
+const mutate = usePacedMutations({
mutationFn: async ({ transaction }) => {
await api.save(transaction.mutations)
},
diff --git a/examples/react/serialized-mutations-demo/index.html b/examples/react/paced-mutations-demo/index.html
similarity index 85%
rename from examples/react/serialized-mutations-demo/index.html
rename to examples/react/paced-mutations-demo/index.html
index 43cfa1d79..c410ccd20 100644
--- a/examples/react/serialized-mutations-demo/index.html
+++ b/examples/react/paced-mutations-demo/index.html
@@ -3,7 +3,7 @@
- Serialized Mutations Demo
+ Paced Mutations Demo
diff --git a/examples/react/serialized-mutations-demo/package.json b/examples/react/paced-mutations-demo/package.json
similarity index 89%
rename from examples/react/serialized-mutations-demo/package.json
rename to examples/react/paced-mutations-demo/package.json
index 3c098c6fa..ec14c651f 100644
--- a/examples/react/serialized-mutations-demo/package.json
+++ b/examples/react/paced-mutations-demo/package.json
@@ -1,5 +1,5 @@
{
- "name": "@tanstack/db-example-serialized-mutations-demo",
+ "name": "@tanstack/db-example-paced-mutations-demo",
"version": "0.0.1",
"private": true,
"type": "module",
diff --git a/examples/react/serialized-mutations-demo/src/App.tsx b/examples/react/paced-mutations-demo/src/App.tsx
similarity index 98%
rename from examples/react/serialized-mutations-demo/src/App.tsx
rename to examples/react/paced-mutations-demo/src/App.tsx
index 826491c33..712b17bda 100644
--- a/examples/react/serialized-mutations-demo/src/App.tsx
+++ b/examples/react/paced-mutations-demo/src/App.tsx
@@ -5,7 +5,7 @@ import {
debounceStrategy,
queueStrategy,
throttleStrategy,
- useSerializedMutations,
+ usePacedMutations,
} from "@tanstack/react-db"
import type { PendingMutation, Transaction } from "@tanstack/react-db"
@@ -155,8 +155,8 @@ export function App() {
[]
)
- // Create the serialized mutations hook
- const mutate = useSerializedMutations({
+ // Create the paced mutations hook
+ const mutate = usePacedMutations({
mutationFn,
strategy,
})
@@ -229,7 +229,7 @@ export function App() {
return (
-
Serialized Mutations Demo
+
Paced Mutations Demo
Drag the slider to trigger mutations and see how different strategies
batch, queue, and persist changes
diff --git a/examples/react/serialized-mutations-demo/src/index.css b/examples/react/paced-mutations-demo/src/index.css
similarity index 100%
rename from examples/react/serialized-mutations-demo/src/index.css
rename to examples/react/paced-mutations-demo/src/index.css
diff --git a/examples/react/serialized-mutations-demo/src/main.tsx b/examples/react/paced-mutations-demo/src/main.tsx
similarity index 100%
rename from examples/react/serialized-mutations-demo/src/main.tsx
rename to examples/react/paced-mutations-demo/src/main.tsx
diff --git a/examples/react/serialized-mutations-demo/tsconfig.json b/examples/react/paced-mutations-demo/tsconfig.json
similarity index 100%
rename from examples/react/serialized-mutations-demo/tsconfig.json
rename to examples/react/paced-mutations-demo/tsconfig.json
diff --git a/examples/react/serialized-mutations-demo/vite.config.ts b/examples/react/paced-mutations-demo/vite.config.ts
similarity index 100%
rename from examples/react/serialized-mutations-demo/vite.config.ts
rename to examples/react/paced-mutations-demo/vite.config.ts
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index 128f8311a..c58c7a5d5 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -13,7 +13,7 @@ export * from "./optimistic-action"
export * from "./local-only"
export * from "./local-storage"
export * from "./errors"
-export * from "./serialized-mutations"
+export * from "./paced-mutations"
export * from "./strategies/index.js"
// Index system exports
diff --git a/packages/db/src/serialized-mutations.ts b/packages/db/src/paced-mutations.ts
similarity index 92%
rename from packages/db/src/serialized-mutations.ts
rename to packages/db/src/paced-mutations.ts
index 433c82b94..683855181 100644
--- a/packages/db/src/serialized-mutations.ts
+++ b/packages/db/src/paced-mutations.ts
@@ -3,9 +3,9 @@ import type { Transaction, TransactionConfig } from "./types"
import type { Strategy } from "./strategies/types"
/**
- * Configuration for creating a serialized mutations manager
+ * Configuration for creating a paced mutations manager
*/
-export interface SerializedMutationsConfig<
+export interface PacedMutationsConfig<
T extends object = Record,
> extends Omit, `autoCommit`> {
/**
@@ -16,7 +16,7 @@ export interface SerializedMutationsConfig<
}
/**
- * Creates a serialized mutations manager with pluggable timing strategies.
+ * Creates a paced mutations manager with pluggable timing strategies.
*
* This function provides a way to control when and how optimistic mutations
* are persisted to the backend, using strategies like debouncing, queuing,
@@ -32,7 +32,7 @@ export interface SerializedMutationsConfig<
* @example
* ```ts
* // Debounced mutations for auto-save
- * const { mutate, cleanup } = createSerializedMutations({
+ * const { mutate, cleanup } = createPacedMutations({
* mutationFn: async ({ transaction }) => {
* await api.save(transaction.mutations)
* },
@@ -54,7 +54,7 @@ export interface SerializedMutationsConfig<
* @example
* ```ts
* // Queue strategy for sequential processing
- * const { mutate } = createSerializedMutations({
+ * const { mutate } = createPacedMutations({
* mutationFn: async ({ transaction }) => {
* await api.save(transaction.mutations)
* },
@@ -66,10 +66,10 @@ export interface SerializedMutationsConfig<
* })
* ```
*/
-export function createSerializedMutations<
+export function createPacedMutations<
T extends object = Record,
>(
- config: SerializedMutationsConfig
+ config: PacedMutationsConfig
): {
mutate: (callback: () => void) => Transaction
} {
diff --git a/packages/react-db/src/index.ts b/packages/react-db/src/index.ts
index 3e645df11..dd1729e95 100644
--- a/packages/react-db/src/index.ts
+++ b/packages/react-db/src/index.ts
@@ -1,6 +1,6 @@
// Re-export all public APIs
export * from "./useLiveQuery"
-export * from "./useSerializedMutations"
+export * from "./usePacedMutations"
export * from "./useLiveInfiniteQuery"
// Re-export everything from @tanstack/db
diff --git a/packages/react-db/src/useSerializedMutations.ts b/packages/react-db/src/usePacedMutations.ts
similarity index 82%
rename from packages/react-db/src/useSerializedMutations.ts
rename to packages/react-db/src/usePacedMutations.ts
index 9ece6767d..90cae5e31 100644
--- a/packages/react-db/src/useSerializedMutations.ts
+++ b/packages/react-db/src/usePacedMutations.ts
@@ -1,9 +1,9 @@
import { useCallback, useMemo } from "react"
-import { createSerializedMutations } from "@tanstack/db"
-import type { SerializedMutationsConfig, Transaction } from "@tanstack/db"
+import { createPacedMutations } from "@tanstack/db"
+import type { PacedMutationsConfig, Transaction } from "@tanstack/db"
/**
- * React hook for managing serialized mutations with timing strategies.
+ * React hook for managing paced mutations with timing strategies.
*
* Provides optimistic mutations with pluggable strategies like debouncing,
* queuing, or throttling. Each call to `mutate` creates mutations that are
@@ -16,7 +16,7 @@ import type { SerializedMutationsConfig, Transaction } from "@tanstack/db"
* ```tsx
* // Debounced auto-save
* function AutoSaveForm() {
- * const mutate = useSerializedMutations({
+ * const mutate = usePacedMutations({
* mutationFn: async ({ transaction }) => {
* await api.save(transaction.mutations)
* },
@@ -47,7 +47,7 @@ import type { SerializedMutationsConfig, Transaction } from "@tanstack/db"
* ```tsx
* // Throttled slider updates
* function VolumeSlider() {
- * const mutate = useSerializedMutations({
+ * const mutate = usePacedMutations({
* mutationFn: async ({ transaction }) => {
* await api.updateVolume(transaction.mutations)
* },
@@ -70,7 +70,7 @@ import type { SerializedMutationsConfig, Transaction } from "@tanstack/db"
* ```tsx
* // Debounce with leading/trailing for color picker (persist first + final only)
* function ColorPicker() {
- * const mutate = useSerializedMutations({
+ * const mutate = usePacedMutations({
* mutationFn: async ({ transaction }) => {
* await api.updateTheme(transaction.mutations)
* },
@@ -92,14 +92,12 @@ import type { SerializedMutationsConfig, Transaction } from "@tanstack/db"
* }
* ```
*/
-export function useSerializedMutations<
- T extends object = Record,
->(
- config: SerializedMutationsConfig
+export function usePacedMutations>(
+ config: PacedMutationsConfig
): (callback: () => void) => Transaction {
- // Create serialized mutations instance with proper dependency tracking
+ // Create paced mutations instance with proper dependency tracking
const { mutate } = useMemo(() => {
- return createSerializedMutations(config)
+ return createPacedMutations(config)
// Include all config properties in dependencies
// Strategy changes will recreate the instance
}, [config.mutationFn, config.metadata, config.strategy, config.id])
diff --git a/packages/react-db/tests/useSerializedMutations.test.tsx b/packages/react-db/tests/usePacedMutations.test.tsx
similarity index 95%
rename from packages/react-db/tests/useSerializedMutations.test.tsx
rename to packages/react-db/tests/usePacedMutations.test.tsx
index 7f9ca9628..a987357c3 100644
--- a/packages/react-db/tests/useSerializedMutations.test.tsx
+++ b/packages/react-db/tests/usePacedMutations.test.tsx
@@ -6,7 +6,7 @@ import {
queueStrategy,
throttleStrategy,
} from "@tanstack/db"
-import { useSerializedMutations } from "../src/useSerializedMutations"
+import { usePacedMutations } from "../src/usePacedMutations"
import { mockSyncCollectionOptionsNoInitialState } from "../../db/tests/utils"
type Item = {
@@ -14,12 +14,12 @@ type Item = {
value: number
}
-describe(`useSerializedMutations with debounce strategy`, () => {
+describe(`usePacedMutations with debounce strategy`, () => {
it(`should batch multiple rapid mutations into a single transaction`, async () => {
const mutationFn = vi.fn(async () => {})
const { result } = renderHook(() =>
- useSerializedMutations({
+ usePacedMutations({
mutationFn,
strategy: debounceStrategy({ wait: 50 }),
})
@@ -102,7 +102,7 @@ describe(`useSerializedMutations with debounce strategy`, () => {
const mutationFn = vi.fn(async () => {})
const { result } = renderHook(() =>
- useSerializedMutations({
+ usePacedMutations({
mutationFn,
strategy: debounceStrategy({ wait: 50 }),
})
@@ -161,7 +161,7 @@ describe(`useSerializedMutations with debounce strategy`, () => {
})
})
-describe(`useSerializedMutations with queue strategy`, () => {
+describe(`usePacedMutations with queue strategy`, () => {
it(`should accumulate mutations then process sequentially`, async () => {
const mutationFn = vi.fn(async () => {
// Quick execution
@@ -169,7 +169,7 @@ describe(`useSerializedMutations with queue strategy`, () => {
})
const { result } = renderHook(() =>
- useSerializedMutations({
+ usePacedMutations({
mutationFn,
strategy: queueStrategy({ wait: 10 }),
})
@@ -213,12 +213,12 @@ describe(`useSerializedMutations with queue strategy`, () => {
})
})
-describe(`useSerializedMutations with throttle strategy`, () => {
+describe(`usePacedMutations with throttle strategy`, () => {
it(`should throttle mutations with leading and trailing execution`, async () => {
const mutationFn = vi.fn(async () => {})
const { result } = renderHook(() =>
- useSerializedMutations({
+ usePacedMutations({
mutationFn,
strategy: throttleStrategy({
wait: 100,
@@ -293,7 +293,7 @@ describe(`useSerializedMutations with throttle strategy`, () => {
const mutationFn = vi.fn(async () => {})
const { result } = renderHook(() =>
- useSerializedMutations({
+ usePacedMutations({
mutationFn,
strategy: throttleStrategy({
wait: 50,
From 05fecc6f8417b74ae7e74da4d79179a5bde7ac40 Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Wed, 22 Oct 2025 10:44:04 -0600
Subject: [PATCH 13/24] update lock
---
pnpm-lock.yaml | 75 +++++++++++++++++++++++---------------------------
1 file changed, 35 insertions(+), 40 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 98bd1b139..698e8fac1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -187,6 +187,40 @@ importers:
specifier: ~5.8.2
version: 5.8.3
+ examples/react/paced-mutations-demo:
+ dependencies:
+ '@tanstack/db':
+ specifier: workspace:*
+ version: link:../../../packages/db
+ '@tanstack/react-db':
+ specifier: workspace:*
+ version: link:../../../packages/react-db
+ mitt:
+ specifier: ^3.0.1
+ version: 3.0.1
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ devDependencies:
+ '@types/react':
+ specifier: ^18.3.12
+ version: 18.3.26
+ '@types/react-dom':
+ specifier: ^18.3.1
+ version: 18.3.7(@types/react@18.3.26)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.7.0(vite@6.4.1(@types/node@24.7.0)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+ vite:
+ specifier: ^6.0.3
+ version: 6.4.1(@types/node@24.7.0)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
+
examples/react/projects:
dependencies:
'@tailwindcss/vite':
@@ -326,40 +360,6 @@ importers:
specifier: ^5.1.0
version: 5.1.0
- examples/react/serialized-mutations-demo:
- dependencies:
- '@tanstack/db':
- specifier: workspace:*
- version: link:../../../packages/db
- '@tanstack/react-db':
- specifier: workspace:*
- version: link:../../../packages/react-db
- mitt:
- specifier: ^3.0.1
- version: 3.0.1
- react:
- specifier: ^18.3.1
- version: 18.3.1
- react-dom:
- specifier: ^18.3.1
- version: 18.3.1(react@18.3.1)
- devDependencies:
- '@types/react':
- specifier: ^18.3.12
- version: 18.3.26
- '@types/react-dom':
- specifier: ^18.3.1
- version: 18.3.7(@types/react@18.3.26)
- '@vitejs/plugin-react':
- specifier: ^4.3.4
- version: 4.7.0(vite@6.4.1(@types/node@24.7.0)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
- typescript:
- specifier: ^5.7.2
- version: 5.9.3
- vite:
- specifier: ^6.0.3
- version: 6.4.1(@types/node@24.7.0)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
-
examples/react/todo:
dependencies:
'@tanstack/electric-db-collection':
@@ -512,7 +512,7 @@ importers:
version: 0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)
drizzle-zod:
specifier: ^0.8.3
- version: 0.8.3(drizzle-orm@0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7))(zod@3.25.76)
+ version: 0.8.3(drizzle-orm@0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7))(zod@4.1.11)
express:
specifier: ^4.21.2
version: 4.21.2
@@ -13216,11 +13216,6 @@ snapshots:
pg: 8.16.3
postgres: 3.4.7
- drizzle-zod@0.8.3(drizzle-orm@0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7))(zod@3.25.76):
- dependencies:
- drizzle-orm: 0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)
- zod: 3.25.76
-
drizzle-zod@0.8.3(drizzle-orm@0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7))(zod@4.1.11):
dependencies:
drizzle-orm: 0.44.6(@types/pg@8.15.5)(gel@2.1.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)
From c99cfd4fd3db4a5729584a6bb618444d89fb7c1f Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Wed, 22 Oct 2025 10:46:54 -0600
Subject: [PATCH 14/24] chore: change paced mutations changeset from minor to
patch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.changeset/paced-mutations.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.changeset/paced-mutations.md b/.changeset/paced-mutations.md
index 84f207b0d..0d4e39919 100644
--- a/.changeset/paced-mutations.md
+++ b/.changeset/paced-mutations.md
@@ -1,6 +1,6 @@
---
-"@tanstack/db": minor
-"@tanstack/react-db": minor
+"@tanstack/db": patch
+"@tanstack/react-db": patch
---
Add paced mutations with pluggable timing strategies
From bb0fb115044be5314fc6a8d42878d76b2bbaeea9 Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Wed, 22 Oct 2025 10:48:27 -0600
Subject: [PATCH 15/24] fix: update remaining references to
useSerializedMutations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Update todo example and queueStrategy JSDoc to use usePacedMutations instead of useSerializedMutations.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
examples/react/todo/src/components/TodoApp.tsx | 8 ++++----
packages/db/src/strategies/queueStrategy.ts | 4 ++--
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/examples/react/todo/src/components/TodoApp.tsx b/examples/react/todo/src/components/TodoApp.tsx
index 356b1a9a5..0fbcb65ed 100644
--- a/examples/react/todo/src/components/TodoApp.tsx
+++ b/examples/react/todo/src/components/TodoApp.tsx
@@ -1,6 +1,6 @@
import React, { useState } from "react"
import { Link } from "@tanstack/react-router"
-import { debounceStrategy, useSerializedMutations } from "@tanstack/react-db"
+import { debounceStrategy, usePacedMutations } from "@tanstack/react-db"
import type { FormEvent } from "react"
import type { Collection, Transaction } from "@tanstack/react-db"
@@ -26,10 +26,10 @@ export function TodoApp({
}: TodoAppProps) {
const [newTodo, setNewTodo] = useState(``)
- // Use serialized mutations with debounce strategy for color picker if mutationFn provided
+ // Use paced mutations with debounce strategy for color picker if mutationFn provided
// Waits for 2500ms of inactivity before persisting - only the final value is saved
const mutateConfig = configMutationFn
- ? useSerializedMutations({
+ ? usePacedMutations({
mutationFn: configMutationFn,
strategy: debounceStrategy({ wait: 2500 }),
})
@@ -48,7 +48,7 @@ export function TodoApp({
// Define a helper function to update config values
const setConfigValue = (key: string, value: string): void => {
if (mutateConfig) {
- // Use serialized mutations for updates (optimistic + batched persistence)
+ // Use paced mutations for updates (optimistic + batched persistence)
mutateConfig(() => {
for (const config of configData) {
if (config.key === key) {
diff --git a/packages/db/src/strategies/queueStrategy.ts b/packages/db/src/strategies/queueStrategy.ts
index f6be6a63f..a0dafac9f 100644
--- a/packages/db/src/strategies/queueStrategy.ts
+++ b/packages/db/src/strategies/queueStrategy.ts
@@ -16,7 +16,7 @@ import type { Transaction } from "../transactions"
* @example
* ```ts
* // FIFO queue - process in order received
- * const mutate = useSerializedMutations({
+ * const mutate = usePacedMutations({
* mutationFn: async ({ transaction }) => {
* await api.save(transaction.mutations)
* },
@@ -31,7 +31,7 @@ import type { Transaction } from "../transactions"
* @example
* ```ts
* // LIFO queue - process most recent first
- * const mutate = useSerializedMutations({
+ * const mutate = usePacedMutations({
* mutationFn: async ({ transaction }) => {
* await api.save(transaction.mutations)
* },
From 90e8f0ab4b2cf964e1f149160c0bbc18aa1c6b3a Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Wed, 22 Oct 2025 10:52:43 -0600
Subject: [PATCH 16/24] docs: mention TanStack Pacer in changeset
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add reference to TanStack Pacer which powers the paced mutations strategies.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.changeset/paced-mutations.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.changeset/paced-mutations.md b/.changeset/paced-mutations.md
index 0d4e39919..c41609d74 100644
--- a/.changeset/paced-mutations.md
+++ b/.changeset/paced-mutations.md
@@ -5,7 +5,7 @@
Add paced mutations with pluggable timing strategies
-Introduces a new paced mutations system that enables optimistic mutations with pluggable timing strategies. This provides fine-grained control over when and how mutations are persisted to the backend.
+Introduces a new paced mutations system that enables optimistic mutations with pluggable timing strategies. This provides fine-grained control over when and how mutations are persisted to the backend. Powered by [TanStack Pacer](https://github.com/TanStack/pacer).
**Core Features:**
From cc237efeda944f591bc05c28c553e542fb32d11d Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Wed, 22 Oct 2025 13:15:27 -0600
Subject: [PATCH 17/24] docs: clarify key design difference between strategies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Make it crystal clear that debounce/throttle only allow one pending tx (collecting mutations) and one persisting tx at a time, while queue guarantees each mutation becomes a separate tx processed in order.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.changeset/paced-mutations.md | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/.changeset/paced-mutations.md b/.changeset/paced-mutations.md
index c41609d74..6329c2963 100644
--- a/.changeset/paced-mutations.md
+++ b/.changeset/paced-mutations.md
@@ -7,18 +7,23 @@ Add paced mutations with pluggable timing strategies
Introduces a new paced mutations system that enables optimistic mutations with pluggable timing strategies. This provides fine-grained control over when and how mutations are persisted to the backend. Powered by [TanStack Pacer](https://github.com/TanStack/pacer).
+**Key Design:**
+
+- **Debounce/Throttle**: Only one pending transaction (collecting mutations) and one persisting transaction (writing to backend) at a time. Multiple rapid mutations automatically merge together.
+- **Queue**: Each mutation creates a separate transaction, guaranteed to run in the order they're made (FIFO by default, configurable to LIFO).
+
**Core Features:**
- **Pluggable Strategy System**: Choose from debounce, queue, or throttle strategies to control mutation timing
-- **Auto-merging Mutations**: Multiple rapid mutations on the same item automatically merge for efficiency
+- **Auto-merging Mutations**: Multiple rapid mutations on the same item automatically merge for efficiency (debounce/throttle only)
- **Transaction Management**: Full transaction lifecycle tracking (pending → persisting → completed/failed)
- **React Hook**: `usePacedMutations` for easy integration in React applications
**Available Strategies:**
-- `debounceStrategy`: Wait for inactivity before persisting (ideal for auto-save, search-as-you-type)
-- `queueStrategy`: Process each mutation as a separate transaction sequentially (defaults to FIFO, configurable to LIFO) (ideal for sequential workflows, rate-limited APIs)
-- `throttleStrategy`: Ensure minimum spacing between executions (ideal for analytics, progress updates)
+- `debounceStrategy`: Wait for inactivity before persisting. Only final state is saved. (ideal for auto-save, search-as-you-type)
+- `queueStrategy`: Each mutation becomes a separate transaction, processed sequentially in order (defaults to FIFO, configurable to LIFO). All mutations are guaranteed to persist. (ideal for sequential workflows, rate-limited APIs)
+- `throttleStrategy`: Ensure minimum spacing between executions. Mutations between executions are merged. (ideal for analytics, progress updates)
**Example Usage:**
From e64f3d3ce81a448f12bc63b968633eedf09d271e Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Wed, 22 Oct 2025 13:26:55 -0600
Subject: [PATCH 18/24] docs: add comprehensive Paced Mutations guide
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add new "Paced Mutations" section to mutations.md covering:
- Introduction to paced mutations and TanStack Pacer
- Key design differences (debounce/throttle vs queue)
- Detailed examples for each strategy (debounce, throttle, queue)
- Guidance on choosing the right strategy
- React hook usage with usePacedMutations
- Non-React usage with createPacedMutations
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
docs/guides/mutations.md | 233 +++++++++++++++++++++++++++++++++++++++
1 file changed, 233 insertions(+)
diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md
index 24e8aba92..f81fea8e6 100644
--- a/docs/guides/mutations.md
+++ b/docs/guides/mutations.md
@@ -100,6 +100,7 @@ The benefits:
- [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)
@@ -892,6 +893,238 @@ tx.isPersisted.promise.then(() => {
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() {
+ const mutate = usePacedMutations({
+ 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(() => {
+ formCollection.update(formId, (draft) => {
+ draft[field] = value
+ })
+ })
+ }
+
+ return (
+
+ )
+}
+```
+
+**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({
+ 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(() => {
+ settingsCollection.update('volume', (draft) => {
+ draft.value = volume
+ })
+ })
+ }
+
+ return (
+ 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, **every mutation is guaranteed to persist**, making it ideal for workflows where you can't lose any operations.
+
+```tsx
+import { usePacedMutations, queueStrategy } from "@tanstack/react-db"
+
+function FileUploader() {
+ const mutate = usePacedMutations({
+ 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, idx) => {
+ mutate(() => {
+ uploadCollection.insert({
+ id: crypto.randomUUID(),
+ file,
+ status: 'pending',
+ })
+ })
+ })
+ }
+
+ return 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 persist
+- Waits for each transaction to complete before starting the next
+
+### 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() {
+ const mutate = usePacedMutations({
+ mutationFn: async ({ transaction }) => {
+ await api.save(transaction.mutations)
+ },
+ strategy: debounceStrategy({ wait: 500 }),
+ })
+
+ // Each mutate call returns a Transaction you can await
+ const handleSave = async () => {
+ const tx = mutate(() => {
+ collection.update(id, (draft) => {
+ draft.value = newValue
+ })
+ })
+
+ // Optionally wait for persistence
+ try {
+ await tx.isPersisted.promise
+ console.log('Saved successfully!')
+ } catch (error) {
+ console.error('Save failed:', error)
+ }
+ }
+
+ return Save
+}
+```
+
+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({
+ mutationFn: async ({ transaction }) => {
+ await api.save(transaction.mutations)
+ },
+ strategy: queueStrategy({ wait: 200 }),
+})
+
+// Use anywhere in your application
+mutate(() => {
+ collection.update(id, updater)
+})
+```
+
## Mutation Merging
When multiple mutations operate on the same item within a transaction, TanStack DB intelligently merges them to:
From 3b03eadaad6fcee5dcb41a3c8e9e36c6cb86dda5 Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Wed, 22 Oct 2025 16:05:09 -0600
Subject: [PATCH 19/24] fix: remove id property from PacedMutationsConfig
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The id property doesn't make sense for paced mutations because:
- Queue strategy creates separate transactions per mutate() call
- Debounce/throttle create multiple transactions over time
- Users shouldn't control internal transaction IDs
Changed PacedMutationsConfig to explicitly define only the properties
that make sense (mutationFn, strategy, metadata) instead of extending
TransactionConfig.
This prevents TypeScript from accepting invalid configuration like:
usePacedMutations({ id: 'foo', ... })
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
packages/db/src/paced-mutations.ts | 14 +++++++++++---
packages/react-db/src/usePacedMutations.ts | 2 +-
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/packages/db/src/paced-mutations.ts b/packages/db/src/paced-mutations.ts
index 683855181..1d55a80a1 100644
--- a/packages/db/src/paced-mutations.ts
+++ b/packages/db/src/paced-mutations.ts
@@ -1,5 +1,5 @@
import { createTransaction } from "./transactions"
-import type { Transaction, TransactionConfig } from "./types"
+import type { MutationFn, Transaction } from "./types"
import type { Strategy } from "./strategies/types"
/**
@@ -7,12 +7,20 @@ import type { Strategy } from "./strategies/types"
*/
export interface PacedMutationsConfig<
T extends object = Record,
-> extends Omit, `autoCommit`> {
+> {
+ /**
+ * Function to execute the mutation on the server
+ */
+ mutationFn: MutationFn
/**
* Strategy for controlling mutation execution timing
- * Examples: debounceStrategy, queueStrategy, throttleStrategy, batchStrategy
+ * Examples: debounceStrategy, queueStrategy, throttleStrategy
*/
strategy: Strategy
+ /**
+ * Custom metadata to associate with transactions
+ */
+ metadata?: Record
}
/**
diff --git a/packages/react-db/src/usePacedMutations.ts b/packages/react-db/src/usePacedMutations.ts
index 90cae5e31..3674f6788 100644
--- a/packages/react-db/src/usePacedMutations.ts
+++ b/packages/react-db/src/usePacedMutations.ts
@@ -100,7 +100,7 @@ export function usePacedMutations>(
return createPacedMutations(config)
// Include all config properties in dependencies
// Strategy changes will recreate the instance
- }, [config.mutationFn, config.metadata, config.strategy, config.id])
+ }, [config.mutationFn, config.metadata, config.strategy])
// Return stable mutate callback
const stableMutate = useCallback(mutate, [mutate])
From 3960b8afc952f7bcdbc7961245ce920bad30205e Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Wed, 22 Oct 2025 18:28:58 -0600
Subject: [PATCH 20/24] fix: prevent unnecessary recreation of paced mutations
instance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixed issue where wrapping usePacedMutations in another hook would
recreate the instance on every render when passing strategy inline:
Before (broken):
usePacedMutations({ strategy: debounceStrategy({ wait: 3000 }) })
// Recreates instance every render because strategy object changes
After (fixed):
// Serializes strategy type + options for stable comparison
// Only recreates when actual values change
Now uses JSON.stringify to create a stable dependency from the
strategy's type and options, so the instance is only recreated when
the strategy configuration actually changes, not when the object
reference changes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
packages/react-db/src/usePacedMutations.ts | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/packages/react-db/src/usePacedMutations.ts b/packages/react-db/src/usePacedMutations.ts
index 3674f6788..21a665698 100644
--- a/packages/react-db/src/usePacedMutations.ts
+++ b/packages/react-db/src/usePacedMutations.ts
@@ -96,11 +96,18 @@ export function usePacedMutations>(
config: PacedMutationsConfig
): (callback: () => void) => Transaction {
// Create paced mutations instance with proper dependency tracking
+ // Serialize strategy for stable comparison since strategy objects are recreated on each render
const { mutate } = useMemo(() => {
return createPacedMutations(config)
- // Include all config properties in dependencies
- // Strategy changes will recreate the instance
- }, [config.mutationFn, config.metadata, config.strategy])
+ }, [
+ config.mutationFn,
+ config.metadata,
+ // Serialize strategy to avoid recreating when object reference changes but values are same
+ JSON.stringify({
+ type: config.strategy._type,
+ options: config.strategy.options,
+ }),
+ ])
// Return stable mutate callback
const stableMutate = useCallback(mutate, [mutate])
From 1f8253f5e8d9c63a35f6fe69ffc92cf0cd0f3b4b Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Wed, 22 Oct 2025 18:30:31 -0600
Subject: [PATCH 21/24] test: add memoization tests for usePacedMutations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add comprehensive tests to verify that usePacedMutations doesn't
recreate the instance unnecessarily when wrapped in custom hooks.
Tests cover:
1. Basic memoization - instance stays same when strategy values are same
2. User's exact scenario - custom hook with inline strategy creation
3. Proper recreation - instance changes when strategy options change
These tests verify the fix for the bug where wrapping usePacedMutations
in a custom hook with inline strategy would recreate the instance on
every render.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.../react-db/tests/usePacedMutations.test.tsx | 112 ++++++++++++++++++
1 file changed, 112 insertions(+)
diff --git a/packages/react-db/tests/usePacedMutations.test.tsx b/packages/react-db/tests/usePacedMutations.test.tsx
index a987357c3..975bdea1e 100644
--- a/packages/react-db/tests/usePacedMutations.test.tsx
+++ b/packages/react-db/tests/usePacedMutations.test.tsx
@@ -345,3 +345,115 @@ describe(`usePacedMutations with throttle strategy`, () => {
expect(tx1!.state).toBe(`completed`)
})
})
+
+describe(`usePacedMutations memoization`, () => {
+ it(`should not recreate instance when strategy object changes but values are same`, () => {
+ const mutationFn = vi.fn(async () => {})
+
+ // Simulate a custom hook that creates strategy inline on each render
+ const useCustomHook = (wait: number) => {
+ return usePacedMutations({
+ mutationFn,
+ // Strategy is created inline on every render - new object reference each time
+ strategy: debounceStrategy({ wait }),
+ })
+ }
+
+ const { result, rerender } = renderHook(({ wait }) => useCustomHook(wait), {
+ initialProps: { wait: 3000 },
+ })
+
+ const firstMutate = result.current
+
+ // Rerender with same wait value - strategy object will be different reference
+ rerender({ wait: 3000 })
+ const secondMutate = result.current
+
+ // mutate function should be stable (same reference)
+ expect(secondMutate).toBe(firstMutate)
+
+ // Rerender with different wait value - should create new instance
+ rerender({ wait: 5000 })
+ const thirdMutate = result.current
+
+ // mutate function should be different now
+ expect(thirdMutate).not.toBe(firstMutate)
+ })
+
+ it(`should not recreate instance when wrapped in custom hook with inline strategy`, () => {
+ const mutationFn = vi.fn(async () => {})
+
+ // Simulate the exact user scenario: custom hook wrapping usePacedMutations
+ const useDebouncedTransaction = (opts?: {
+ wait?: number
+ trailing?: boolean
+ leading?: boolean
+ }) => {
+ return usePacedMutations({
+ mutationFn,
+ strategy: debounceStrategy({
+ wait: opts?.wait ?? 3000,
+ trailing: opts?.trailing ?? true,
+ leading: opts?.leading ?? false,
+ }),
+ })
+ }
+
+ const { result, rerender } = renderHook(() => useDebouncedTransaction())
+
+ const firstMutate = result.current
+
+ // Multiple rerenders with no options - should not recreate instance
+ rerender()
+ expect(result.current).toBe(firstMutate)
+
+ rerender()
+ expect(result.current).toBe(firstMutate)
+
+ rerender()
+ expect(result.current).toBe(firstMutate)
+
+ // All should still be the same mutate function
+ expect(result.current).toBe(firstMutate)
+ })
+
+ it(`should recreate instance when strategy options actually change`, () => {
+ const mutationFn = vi.fn(async () => {})
+
+ const useDebouncedTransaction = (opts?: {
+ wait?: number
+ trailing?: boolean
+ leading?: boolean
+ }) => {
+ return usePacedMutations({
+ mutationFn,
+ strategy: debounceStrategy({
+ wait: opts?.wait ?? 3000,
+ trailing: opts?.trailing ?? true,
+ leading: opts?.leading ?? false,
+ }),
+ })
+ }
+
+ const { result, rerender } = renderHook(
+ ({ opts }) => useDebouncedTransaction(opts),
+ { initialProps: { opts: { wait: 3000 } } }
+ )
+
+ const firstMutate = result.current
+
+ // Rerender with different wait value
+ rerender({ opts: { wait: 5000 } })
+ const secondMutate = result.current
+
+ // Should be different instance since wait changed
+ expect(secondMutate).not.toBe(firstMutate)
+
+ // Rerender with same wait value again
+ rerender({ opts: { wait: 5000 } })
+ const thirdMutate = result.current
+
+ // Should be same as second since value didn't change
+ expect(thirdMutate).toBe(secondMutate)
+ })
+})
From 7b4e55a6b6b010ea30a2948f2cae2aeba5e6466c Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Thu, 23 Oct 2025 10:01:12 -0600
Subject: [PATCH 22/24] fix: stabilize mutationFn to prevent recreating paced
mutations instance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wrap the user-provided mutationFn in a stable callback using useRef,
so that even if the mutationFn reference changes on each render,
the paced mutations instance is not recreated.
This fixes the bug where:
1. User types "123" in a textarea
2. Each keystroke recreates the instance (new mutationFn on each render)
3. Each call to mutate() gets a different transaction ID
4. Old transactions with stale data (e.g. "12") are still pending
5. When they complete, they overwrite the correct "123" value
Now the mutationFn identity is stable, so the same paced mutations
instance is reused across renders, and all mutations during the
debounce window batch into the same transaction.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
packages/react-db/src/usePacedMutations.ts | 18 +++++++++++++++---
1 file changed, 15 insertions(+), 3 deletions(-)
diff --git a/packages/react-db/src/usePacedMutations.ts b/packages/react-db/src/usePacedMutations.ts
index 21a665698..b50b19dfc 100644
--- a/packages/react-db/src/usePacedMutations.ts
+++ b/packages/react-db/src/usePacedMutations.ts
@@ -1,4 +1,4 @@
-import { useCallback, useMemo } from "react"
+import { useCallback, useMemo, useRef } from "react"
import { createPacedMutations } from "@tanstack/db"
import type { PacedMutationsConfig, Transaction } from "@tanstack/db"
@@ -95,12 +95,24 @@ import type { PacedMutationsConfig, Transaction } from "@tanstack/db"
export function usePacedMutations>(
config: PacedMutationsConfig
): (callback: () => void) => Transaction {
+ // Keep a ref to the latest mutationFn so we can call it without recreating the instance
+ const mutationFnRef = useRef(config.mutationFn)
+ mutationFnRef.current = config.mutationFn
+
+ // Create a stable wrapper around mutationFn that always calls the latest version
+ const stableMutationFn = useCallback((params) => {
+ return mutationFnRef.current(params)
+ }, [])
+
// Create paced mutations instance with proper dependency tracking
// Serialize strategy for stable comparison since strategy objects are recreated on each render
const { mutate } = useMemo(() => {
- return createPacedMutations(config)
+ return createPacedMutations({
+ ...config,
+ mutationFn: stableMutationFn,
+ })
}, [
- config.mutationFn,
+ stableMutationFn,
config.metadata,
// Serialize strategy to avoid recreating when object reference changes but values are same
JSON.stringify({
From a403d52525f7250470fd73b83760e4780afbeedf Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 27 Oct 2025 13:30:00 +0000
Subject: [PATCH 23/24] Refactor paced mutations to work like
createOptimisticAction
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Modified the paced mutations API to follow the same pattern as
createOptimisticAction, where the hook takes an onMutate callback
and you pass the actual update variables directly to the mutate
function.
Changes:
- Updated PacedMutationsConfig to accept onMutate callback
- Modified createPacedMutations to accept variables instead of callback
- Updated usePacedMutations hook to handle the new API
- Fixed all tests to use the new API with onMutate
- Updated documentation and examples to reflect the new pattern
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
packages/db/src/paced-mutations.ts | 70 ++++---
packages/react-db/src/usePacedMutations.ts | 82 ++++----
.../react-db/tests/usePacedMutations.test.tsx | 187 ++++++++++--------
pnpm-lock.yaml | 138 +++++++++++--
4 files changed, 308 insertions(+), 169 deletions(-)
diff --git a/packages/db/src/paced-mutations.ts b/packages/db/src/paced-mutations.ts
index 1d55a80a1..1a85957c7 100644
--- a/packages/db/src/paced-mutations.ts
+++ b/packages/db/src/paced-mutations.ts
@@ -6,10 +6,17 @@ import type { Strategy } from "./strategies/types"
* Configuration for creating a paced mutations manager
*/
export interface PacedMutationsConfig<
+ TVariables = unknown,
T extends object = Record,
> {
/**
- * Function to execute the mutation on the server
+ * Callback to apply optimistic updates immediately.
+ * Receives the variables passed to the mutate function.
+ */
+ onMutate: (variables: TVariables) => void
+ /**
+ * Function to execute the mutation on the server.
+ * Receives the transaction parameters containing all merged mutations.
*/
mutationFn: MutationFn
/**
@@ -28,42 +35,45 @@ export interface PacedMutationsConfig<
*
* This function provides a way to control when and how optimistic mutations
* are persisted to the backend, using strategies like debouncing, queuing,
- * or throttling. Each call to `mutate` creates mutations that are auto-merged
- * and persisted according to the strategy.
+ * or throttling. The optimistic updates are applied immediately via `onMutate`,
+ * and the actual persistence is controlled by the strategy.
*
- * The returned `mutate` function returns a Transaction object that can be
- * awaited to know when persistence completes or to handle errors.
+ * The returned function accepts variables of type TVariables and returns a
+ * Transaction object that can be awaited to know when persistence completes
+ * or to handle errors.
*
- * @param config - Configuration including mutationFn and strategy
- * @returns Object with mutate function and cleanup
+ * @param config - Configuration including onMutate, mutationFn and strategy
+ * @returns A function that accepts variables and returns a Transaction
*
* @example
* ```ts
* // Debounced mutations for auto-save
- * const { mutate, cleanup } = createPacedMutations({
- * mutationFn: async ({ transaction }) => {
+ * const updateTodo = createPacedMutations({
+ * onMutate: (text) => {
+ * // Apply optimistic update immediately
+ * collection.update(id, draft => { draft.text = text })
+ * },
+ * mutationFn: async (text, { transaction }) => {
* await api.save(transaction.mutations)
* },
* strategy: debounceStrategy({ wait: 500 })
* })
*
- * // Each mutate call returns a transaction
- * const tx = mutate(() => {
- * collection.update(id, draft => { draft.value = newValue })
- * })
+ * // Call with variables, returns a transaction
+ * const tx = updateTodo('New text')
*
* // Await persistence or handle errors
* await tx.isPersisted.promise
- *
- * // Cleanup when done
- * cleanup()
* ```
*
* @example
* ```ts
* // Queue strategy for sequential processing
- * const { mutate } = createPacedMutations({
- * mutationFn: async ({ transaction }) => {
+ * const addTodo = createPacedMutations<{ text: string }>({
+ * onMutate: ({ text }) => {
+ * collection.insert({ id: uuid(), text, completed: false })
+ * },
+ * mutationFn: async ({ text }, { transaction }) => {
* await api.save(transaction.mutations)
* },
* strategy: queueStrategy({
@@ -75,13 +85,12 @@ export interface PacedMutationsConfig<
* ```
*/
export function createPacedMutations<
+ TVariables = unknown,
T extends object = Record,
>(
- config: PacedMutationsConfig
-): {
- mutate: (callback: () => void) => Transaction
-} {
- const { strategy, ...transactionConfig } = config
+ config: PacedMutationsConfig
+): (variables: TVariables) => Transaction {
+ const { onMutate, mutationFn, strategy, ...transactionConfig } = config
// The currently active transaction (pending, not yet persisting)
let activeTransaction: Transaction | null = null
@@ -115,21 +124,24 @@ export function createPacedMutations<
}
/**
- * Executes a mutation callback. Creates a new transaction if none is active,
+ * Executes a mutation with the given variables. Creates a new transaction if none is active,
* or adds to the existing active transaction. The strategy controls when
* the transaction is actually committed.
*/
- function mutate(callback: () => void): Transaction {
+ function mutate(variables: TVariables): Transaction {
// Create a new transaction if we don't have an active one
if (!activeTransaction || activeTransaction.state !== `pending`) {
activeTransaction = createTransaction({
...transactionConfig,
+ mutationFn,
autoCommit: false,
})
}
- // Execute the mutation callback to add mutations to the active transaction
- activeTransaction.mutate(callback)
+ // Execute onMutate with variables to apply optimistic updates
+ activeTransaction.mutate(() => {
+ onMutate(variables)
+ })
// Save reference before calling strategy.execute
const txToReturn = activeTransaction
@@ -153,7 +165,5 @@ export function createPacedMutations<
return txToReturn
}
- return {
- mutate,
- }
+ return mutate
}
diff --git a/packages/react-db/src/usePacedMutations.ts b/packages/react-db/src/usePacedMutations.ts
index b50b19dfc..a86e4efdc 100644
--- a/packages/react-db/src/usePacedMutations.ts
+++ b/packages/react-db/src/usePacedMutations.ts
@@ -6,17 +6,23 @@ import type { PacedMutationsConfig, Transaction } from "@tanstack/db"
* React hook for managing paced mutations with timing strategies.
*
* Provides optimistic mutations with pluggable strategies like debouncing,
- * queuing, or throttling. Each call to `mutate` creates mutations that are
- * auto-merged and persisted according to the strategy.
+ * queuing, or throttling. The optimistic updates are applied immediately via
+ * `onMutate`, and the actual persistence is controlled by the strategy.
*
- * @param config - Configuration including mutationFn and strategy
- * @returns A mutate function that executes mutations and returns Transaction objects
+ * @param config - Configuration including onMutate, mutationFn and strategy
+ * @returns A mutate function that accepts variables and returns Transaction objects
*
* @example
* ```tsx
* // Debounced auto-save
- * function AutoSaveForm() {
- * const mutate = usePacedMutations({
+ * function AutoSaveForm({ formId }: { formId: string }) {
+ * const mutate = usePacedMutations({
+ * onMutate: (value) => {
+ * // Apply optimistic update immediately
+ * formCollection.update(formId, draft => {
+ * draft.content = value
+ * })
+ * },
* mutationFn: async ({ transaction }) => {
* await api.save(transaction.mutations)
* },
@@ -24,11 +30,7 @@ import type { PacedMutationsConfig, Transaction } from "@tanstack/db"
* })
*
* const handleChange = async (value: string) => {
- * const tx = mutate(() => {
- * formCollection.update(formId, draft => {
- * draft.content = value
- * })
- * })
+ * const tx = mutate(value)
*
* // Optional: await persistence or handle errors
* try {
@@ -47,22 +49,19 @@ import type { PacedMutationsConfig, Transaction } from "@tanstack/db"
* ```tsx
* // Throttled slider updates
* function VolumeSlider() {
- * const mutate = usePacedMutations({
+ * const mutate = usePacedMutations({
+ * onMutate: (volume) => {
+ * settingsCollection.update('volume', draft => {
+ * draft.value = volume
+ * })
+ * },
* mutationFn: async ({ transaction }) => {
* await api.updateVolume(transaction.mutations)
* },
* strategy: throttleStrategy({ wait: 200 })
* })
*
- * const handleVolumeChange = (volume: number) => {
- * mutate(() => {
- * settingsCollection.update('volume', draft => {
- * draft.value = volume
- * })
- * })
- * }
- *
- * return handleVolumeChange(+e.target.value)} />
+ * return mutate(+e.target.value)} />
* }
* ```
*
@@ -70,7 +69,12 @@ import type { PacedMutationsConfig, Transaction } from "@tanstack/db"
* ```tsx
* // Debounce with leading/trailing for color picker (persist first + final only)
* function ColorPicker() {
- * const mutate = usePacedMutations({
+ * const mutate = usePacedMutations({
+ * onMutate: (color) => {
+ * themeCollection.update('primary', draft => {
+ * draft.color = color
+ * })
+ * },
* mutationFn: async ({ transaction }) => {
* await api.updateTheme(transaction.mutations)
* },
@@ -80,38 +84,44 @@ import type { PacedMutationsConfig, Transaction } from "@tanstack/db"
* return (
* {
- * mutate(() => {
- * themeCollection.update('primary', draft => {
- * draft.color = e.target.value
- * })
- * })
- * }}
+ * onChange={e => mutate(e.target.value)}
* />
* )
* }
* ```
*/
-export function usePacedMutations>(
- config: PacedMutationsConfig
-): (callback: () => void) => Transaction {
- // Keep a ref to the latest mutationFn so we can call it without recreating the instance
+export function usePacedMutations<
+ TVariables = unknown,
+ T extends object = Record,
+>(
+ config: PacedMutationsConfig
+): (variables: TVariables) => Transaction {
+ // Keep refs to the latest callbacks so we can call them without recreating the instance
+ const onMutateRef = useRef(config.onMutate)
+ onMutateRef.current = config.onMutate
+
const mutationFnRef = useRef(config.mutationFn)
mutationFnRef.current = config.mutationFn
- // Create a stable wrapper around mutationFn that always calls the latest version
+ // Create stable wrappers that always call the latest version
+ const stableOnMutate = useCallback((variables) => {
+ return onMutateRef.current(variables)
+ }, [])
+
const stableMutationFn = useCallback((params) => {
return mutationFnRef.current(params)
}, [])
// Create paced mutations instance with proper dependency tracking
// Serialize strategy for stable comparison since strategy objects are recreated on each render
- const { mutate } = useMemo(() => {
- return createPacedMutations({
+ const mutate = useMemo(() => {
+ return createPacedMutations({
...config,
+ onMutate: stableOnMutate,
mutationFn: stableMutationFn,
})
}, [
+ stableOnMutate,
stableMutationFn,
config.metadata,
// Serialize strategy to avoid recreating when object reference changes but values are same
diff --git a/packages/react-db/tests/usePacedMutations.test.tsx b/packages/react-db/tests/usePacedMutations.test.tsx
index 975bdea1e..61d1643e4 100644
--- a/packages/react-db/tests/usePacedMutations.test.tsx
+++ b/packages/react-db/tests/usePacedMutations.test.tsx
@@ -18,13 +18,6 @@ describe(`usePacedMutations with debounce strategy`, () => {
it(`should batch multiple rapid mutations into a single transaction`, async () => {
const mutationFn = vi.fn(async () => {})
- const { result } = renderHook(() =>
- usePacedMutations({
- mutationFn,
- strategy: debounceStrategy({ wait: 50 }),
- })
- )
-
const collection = createCollection(
mockSyncCollectionOptionsNoInitialState({
id: `test`,
@@ -39,29 +32,37 @@ describe(`usePacedMutations with debounce strategy`, () => {
collection.utils.markReady()
await preloadPromise
+ let insertCount = 0
+ const { result } = renderHook(() =>
+ usePacedMutations<{ id: number; value: number }>({
+ onMutate: (item) => {
+ if (insertCount === 0) {
+ collection.insert(item)
+ insertCount++
+ } else {
+ collection.update(item.id, (draft) => {
+ draft.value = item.value
+ })
+ }
+ },
+ mutationFn,
+ strategy: debounceStrategy({ wait: 50 }),
+ })
+ )
+
let tx1, tx2, tx3
// Trigger three rapid mutations (all within 50ms debounce window)
act(() => {
- tx1 = result.current(() => {
- collection.insert({ id: 1, value: 1 })
- })
+ tx1 = result.current({ id: 1, value: 1 })
})
act(() => {
- tx2 = result.current(() => {
- collection.update(1, (draft) => {
- draft.value = 2
- })
- })
+ tx2 = result.current({ id: 1, value: 2 })
})
act(() => {
- tx3 = result.current(() => {
- collection.update(1, (draft) => {
- draft.value = 3
- })
- })
+ tx3 = result.current({ id: 1, value: 3 })
})
// All three calls should return the SAME transaction object
@@ -101,13 +102,6 @@ describe(`usePacedMutations with debounce strategy`, () => {
it(`should reset debounce timer on each new mutation`, async () => {
const mutationFn = vi.fn(async () => {})
- const { result } = renderHook(() =>
- usePacedMutations({
- mutationFn,
- strategy: debounceStrategy({ wait: 50 }),
- })
- )
-
const collection = createCollection(
mockSyncCollectionOptionsNoInitialState({
id: `test`,
@@ -121,11 +115,27 @@ describe(`usePacedMutations with debounce strategy`, () => {
collection.utils.markReady()
await preloadPromise
+ let insertCount = 0
+ const { result } = renderHook(() =>
+ usePacedMutations<{ id: number; value: number }>({
+ onMutate: (item) => {
+ if (insertCount === 0) {
+ collection.insert(item)
+ insertCount++
+ } else {
+ collection.update(item.id, (draft) => {
+ draft.value = item.value
+ })
+ }
+ },
+ mutationFn,
+ strategy: debounceStrategy({ wait: 50 }),
+ })
+ )
+
// First mutation at t=0
act(() => {
- result.current(() => {
- collection.insert({ id: 1, value: 1 })
- })
+ result.current({ id: 1, value: 1 })
})
// Wait 40ms (still within 50ms debounce window)
@@ -136,11 +146,7 @@ describe(`usePacedMutations with debounce strategy`, () => {
// Second mutation at t=40 (resets the timer)
act(() => {
- result.current(() => {
- collection.update(1, (draft) => {
- draft.value = 2
- })
- })
+ result.current({ id: 1, value: 2 })
})
// Wait another 40ms (t=80, but only 40ms since last mutation)
@@ -168,13 +174,6 @@ describe(`usePacedMutations with queue strategy`, () => {
await new Promise((resolve) => setTimeout(resolve, 5))
})
- const { result } = renderHook(() =>
- usePacedMutations({
- mutationFn,
- strategy: queueStrategy({ wait: 10 }),
- })
- )
-
const collection = createCollection(
mockSyncCollectionOptionsNoInitialState({
id: `test`,
@@ -189,15 +188,27 @@ describe(`usePacedMutations with queue strategy`, () => {
collection.utils.markReady()
await preloadPromise
+ const { result } = renderHook(() =>
+ usePacedMutations({
+ onMutate: (item) => {
+ collection.insert(item)
+ },
+ mutationFn,
+ strategy: queueStrategy({ wait: 10 }),
+ })
+ )
+
let tx1
- // Trigger rapid mutations within single act - they accumulate in one transaction
+ // Trigger rapid mutations - queue creates separate transactions
act(() => {
- tx1 = result.current(() => {
- collection.insert({ id: 1, value: 1 })
- collection.insert({ id: 2, value: 2 })
- collection.insert({ id: 3, value: 3 })
- })
+ tx1 = result.current({ id: 1, value: 1 })
+ })
+ act(() => {
+ result.current({ id: 2, value: 2 })
+ })
+ act(() => {
+ result.current({ id: 3, value: 3 })
})
// Queue starts processing immediately
@@ -208,8 +219,8 @@ describe(`usePacedMutations with queue strategy`, () => {
await tx1!.isPersisted.promise
expect(tx1!.state).toBe(`completed`)
- // All 3 mutations should be in the same transaction
- expect(tx1!.mutations).toHaveLength(3)
+ // Each mutation should be in its own transaction
+ expect(tx1!.mutations).toHaveLength(1)
})
})
@@ -217,17 +228,6 @@ describe(`usePacedMutations with throttle strategy`, () => {
it(`should throttle mutations with leading and trailing execution`, async () => {
const mutationFn = vi.fn(async () => {})
- const { result } = renderHook(() =>
- usePacedMutations({
- mutationFn,
- strategy: throttleStrategy({
- wait: 100,
- leading: true,
- trailing: true,
- }),
- })
- )
-
const collection = createCollection(
mockSyncCollectionOptionsNoInitialState({
id: `test`,
@@ -242,13 +242,25 @@ describe(`usePacedMutations with throttle strategy`, () => {
collection.utils.markReady()
await preloadPromise
+ const { result } = renderHook(() =>
+ usePacedMutations({
+ onMutate: (item) => {
+ collection.insert(item)
+ },
+ mutationFn,
+ strategy: throttleStrategy({
+ wait: 100,
+ leading: true,
+ trailing: true,
+ }),
+ })
+ )
+
let tx1, tx2, tx3
// First mutation at t=0 (should execute immediately due to leading: true)
act(() => {
- tx1 = result.current(() => {
- collection.insert({ id: 1, value: 1 })
- })
+ tx1 = result.current({ id: 1, value: 1 })
})
// Leading edge should execute immediately
@@ -258,17 +270,13 @@ describe(`usePacedMutations with throttle strategy`, () => {
// Second mutation at t=20 (during throttle period, should batch)
act(() => {
- tx2 = result.current(() => {
- collection.insert({ id: 2, value: 2 })
- })
+ tx2 = result.current({ id: 2, value: 2 })
})
// Third mutation at t=30 (during throttle period, should batch with second)
await new Promise((resolve) => setTimeout(resolve, 10))
act(() => {
- tx3 = result.current(() => {
- collection.insert({ id: 3, value: 3 })
- })
+ tx3 = result.current({ id: 3, value: 3 })
})
// tx2 and tx3 should be the same transaction (batched)
@@ -292,17 +300,6 @@ describe(`usePacedMutations with throttle strategy`, () => {
it(`should respect trailing: true with leading: false option`, async () => {
const mutationFn = vi.fn(async () => {})
- const { result } = renderHook(() =>
- usePacedMutations({
- mutationFn,
- strategy: throttleStrategy({
- wait: 50,
- leading: false,
- trailing: true,
- }),
- })
- )
-
const collection = createCollection(
mockSyncCollectionOptionsNoInitialState({
id: `test`,
@@ -316,13 +313,25 @@ describe(`usePacedMutations with throttle strategy`, () => {
collection.utils.markReady()
await preloadPromise
+ const { result } = renderHook(() =>
+ usePacedMutations({
+ onMutate: (item) => {
+ collection.insert(item)
+ },
+ mutationFn,
+ strategy: throttleStrategy({
+ wait: 50,
+ leading: false,
+ trailing: true,
+ }),
+ })
+ )
+
let tx1
// First mutation should NOT execute immediately with leading: false
act(() => {
- tx1 = result.current(() => {
- collection.insert({ id: 1, value: 1 })
- })
+ tx1 = result.current({ id: 1, value: 1 })
})
// Should not have been called yet
@@ -331,9 +340,7 @@ describe(`usePacedMutations with throttle strategy`, () => {
// Add another mutation during throttle period to ensure trailing fires
act(() => {
- result.current(() => {
- collection.insert({ id: 2, value: 2 })
- })
+ result.current({ id: 2, value: 2 })
})
// Wait for throttle period to complete
@@ -349,10 +356,12 @@ describe(`usePacedMutations with throttle strategy`, () => {
describe(`usePacedMutations memoization`, () => {
it(`should not recreate instance when strategy object changes but values are same`, () => {
const mutationFn = vi.fn(async () => {})
+ const onMutate = vi.fn(() => {})
// Simulate a custom hook that creates strategy inline on each render
const useCustomHook = (wait: number) => {
return usePacedMutations({
+ onMutate,
mutationFn,
// Strategy is created inline on every render - new object reference each time
strategy: debounceStrategy({ wait }),
@@ -382,6 +391,7 @@ describe(`usePacedMutations memoization`, () => {
it(`should not recreate instance when wrapped in custom hook with inline strategy`, () => {
const mutationFn = vi.fn(async () => {})
+ const onMutate = vi.fn(() => {})
// Simulate the exact user scenario: custom hook wrapping usePacedMutations
const useDebouncedTransaction = (opts?: {
@@ -390,6 +400,7 @@ describe(`usePacedMutations memoization`, () => {
leading?: boolean
}) => {
return usePacedMutations({
+ onMutate,
mutationFn,
strategy: debounceStrategy({
wait: opts?.wait ?? 3000,
@@ -419,6 +430,7 @@ describe(`usePacedMutations memoization`, () => {
it(`should recreate instance when strategy options actually change`, () => {
const mutationFn = vi.fn(async () => {})
+ const onMutate = vi.fn(() => {})
const useDebouncedTransaction = (opts?: {
wait?: number
@@ -426,6 +438,7 @@ describe(`usePacedMutations memoization`, () => {
leading?: boolean
}) => {
return usePacedMutations({
+ onMutate,
mutationFn,
strategy: debounceStrategy({
wait: opts?.wait ?? 3000,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 51873128e..a2608e353 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -319,7 +319,7 @@ importers:
version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
'@vitejs/plugin-react':
specifier: ^5.0.4
- version: 5.1.0(vite@6.4.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
+ version: 5.0.4(vite@6.4.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
concurrently:
specifier: ^9.2.1
version: 9.2.1
@@ -449,7 +449,7 @@ importers:
version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
'@vitejs/plugin-react':
specifier: ^5.0.3
- version: 5.1.0(vite@6.4.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
+ version: 5.0.4(vite@6.4.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
concurrently:
specifier: ^9.2.1
version: 9.2.1
@@ -2883,6 +2883,9 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.29':
resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==}
+ '@rolldown/pluginutils@1.0.0-beta.38':
+ resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==}
+
'@rolldown/pluginutils@1.0.0-beta.40':
resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==}
@@ -3858,6 +3861,12 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/project-service@8.46.1':
+ resolution: {integrity: sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/project-service@8.46.2':
resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3868,6 +3877,10 @@ packages:
resolution: {integrity: sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/scope-manager@8.46.1':
+ resolution: {integrity: sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/scope-manager@8.46.2':
resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3878,6 +3891,12 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/tsconfig-utils@8.46.1':
+ resolution: {integrity: sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/tsconfig-utils@8.46.2':
resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3902,6 +3921,10 @@ packages:
resolution: {integrity: sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/types@8.46.1':
+ resolution: {integrity: sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/types@8.46.2':
resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3912,6 +3935,12 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/typescript-estree@8.46.1':
+ resolution: {integrity: sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/typescript-estree@8.46.2':
resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3925,6 +3954,13 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
+ '@typescript-eslint/utils@8.46.1':
+ resolution: {integrity: sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
'@typescript-eslint/utils@8.46.2':
resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3936,6 +3972,10 @@ packages:
resolution: {integrity: sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/visitor-keys@8.46.1':
+ resolution: {integrity: sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/visitor-keys@8.46.2':
resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4050,6 +4090,12 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ '@vitejs/plugin-react@5.0.4':
+ resolution: {integrity: sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
'@vitejs/plugin-react@5.1.0':
resolution: {integrity: sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -6078,6 +6124,10 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
+ jiti@2.6.0:
+ resolution: {integrity: sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==}
+ hasBin: true
+
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -10741,6 +10791,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.29': {}
+ '@rolldown/pluginutils@1.0.0-beta.38': {}
+
'@rolldown/pluginutils@1.0.0-beta.40': {}
'@rolldown/pluginutils@1.0.0-beta.43': {}
@@ -11115,7 +11167,7 @@ snapshots:
'@stylistic/eslint-plugin@4.4.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.38.0(jiti@2.6.1)
eslint-visitor-keys: 4.2.1
espree: 10.4.0
@@ -11128,7 +11180,7 @@ snapshots:
'@stylistic/eslint-plugin@5.4.0(eslint@9.38.0(jiti@2.6.1))':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1))
- '@typescript-eslint/types': 8.46.2
+ '@typescript-eslint/types': 8.46.1
eslint: 9.38.0(jiti@2.6.1)
eslint-visitor-keys: 4.2.1
espree: 10.4.0
@@ -12021,8 +12073,17 @@ snapshots:
'@typescript-eslint/project-service@8.44.1(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3)
- '@typescript-eslint/types': 8.46.2
+ '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.46.1
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.46.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.46.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
@@ -12042,6 +12103,11 @@ snapshots:
'@typescript-eslint/types': 8.44.1
'@typescript-eslint/visitor-keys': 8.44.1
+ '@typescript-eslint/scope-manager@8.46.1':
+ dependencies:
+ '@typescript-eslint/types': 8.46.1
+ '@typescript-eslint/visitor-keys': 8.46.1
+
'@typescript-eslint/scope-manager@8.46.2':
dependencies:
'@typescript-eslint/types': 8.46.2
@@ -12051,6 +12117,10 @@ snapshots:
dependencies:
typescript: 5.9.3
+ '@typescript-eslint/tsconfig-utils@8.46.1(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
'@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
@@ -12081,6 +12151,8 @@ snapshots:
'@typescript-eslint/types@8.44.1': {}
+ '@typescript-eslint/types@8.46.1': {}
+
'@typescript-eslint/types@8.46.2': {}
'@typescript-eslint/typescript-estree@8.44.1(typescript@5.9.3)':
@@ -12099,6 +12171,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/typescript-estree@8.46.1(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.46.1(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.46.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.46.1
+ '@typescript-eslint/visitor-keys': 8.46.1
+ debug: 4.4.3
+ fast-glob: 3.3.3
+ is-glob: 4.0.3
+ minimatch: 9.0.5
+ semver: 7.7.3
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.46.2(typescript@5.9.3)
@@ -12126,6 +12214,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/utils@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1))
+ '@typescript-eslint/scope-manager': 8.46.1
+ '@typescript-eslint/types': 8.46.1
+ '@typescript-eslint/typescript-estree': 8.46.1(typescript@5.9.3)
+ eslint: 9.38.0(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
'@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.38.0(jiti@2.6.1))
@@ -12142,6 +12241,11 @@ snapshots:
'@typescript-eslint/types': 8.44.1
eslint-visitor-keys: 4.2.1
+ '@typescript-eslint/visitor-keys@8.46.1':
+ dependencies:
+ '@typescript-eslint/types': 8.46.1
+ eslint-visitor-keys: 4.2.1
+
'@typescript-eslint/visitor-keys@8.46.2':
dependencies:
'@typescript-eslint/types': 8.46.2
@@ -12224,14 +12328,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@vitejs/plugin-react@5.1.0(vite@6.4.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))':
+ '@vitejs/plugin-react@5.0.4(vite@6.4.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))':
dependencies:
'@babel/core': 7.28.4
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4)
- '@rolldown/pluginutils': 1.0.0-beta.43
+ '@rolldown/pluginutils': 1.0.0-beta.38
'@types/babel__core': 7.20.5
- react-refresh: 0.18.0
+ react-refresh: 0.17.0
vite: 6.4.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
@@ -13553,7 +13657,7 @@ snapshots:
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1)):
dependencies:
- '@typescript-eslint/types': 8.46.2
+ '@typescript-eslint/types': 8.46.1
comment-parser: 1.4.1
debug: 4.4.3
eslint: 9.38.0(jiti@2.6.1)
@@ -13624,7 +13728,7 @@ snapshots:
eslint-plugin-solid@0.14.5(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/utils': 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.38.0(jiti@2.6.1)
estraverse: 5.3.0
is-html: 2.0.0
@@ -14585,6 +14689,8 @@ snapshots:
jiti@1.21.7: {}
+ jiti@2.6.0: {}
+
jiti@2.6.1: {}
jju@1.4.0: {}
@@ -14747,7 +14853,7 @@ snapshots:
'@types/node': 24.7.0
fast-glob: 3.3.3
formatly: 0.3.0
- jiti: 2.6.1
+ jiti: 2.6.0
js-yaml: 4.1.0
minimist: 1.2.8
oxc-resolver: 11.8.4
@@ -17108,11 +17214,11 @@ snapshots:
vite@6.4.1(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1):
dependencies:
- esbuild: 0.25.11
+ esbuild: 0.25.9
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
- rollup: 4.52.5
+ rollup: 4.52.3
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.18.1
@@ -17126,11 +17232,11 @@ snapshots:
vite@6.4.1(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1):
dependencies:
- esbuild: 0.25.11
+ esbuild: 0.25.9
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
- rollup: 4.52.5
+ rollup: 4.52.3
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.7.0
From 755d7062980fc8e0cf9ec95670a24d4ab43a15ef Mon Sep 17 00:00:00 2001
From: Kyle Mathews
Date: Mon, 27 Oct 2025 16:25:20 +0100
Subject: [PATCH 24/24] Update paced mutations demo to use new onMutate API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Modified the example to use the new variables-based API where you pass
the value directly to mutate() and provide an onMutate callback for
optimistic updates. This aligns with the createOptimisticAction pattern.
Changes:
- Removed useCallback wrappers (hook handles stabilization internally)
- Pass newValue directly to mutate() instead of a callback
- Simplified code since hook manages ref stability
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.../react/paced-mutations-demo/src/App.tsx | 31 +++++++++----------
1 file changed, 14 insertions(+), 17 deletions(-)
diff --git a/examples/react/paced-mutations-demo/src/App.tsx b/examples/react/paced-mutations-demo/src/App.tsx
index 712b17bda..57f1ad33c 100644
--- a/examples/react/paced-mutations-demo/src/App.tsx
+++ b/examples/react/paced-mutations-demo/src/App.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from "react"
+import { useEffect, useMemo, useState } from "react"
import mitt from "mitt"
import {
createCollection,
@@ -110,9 +110,16 @@ export function App() {
}
}, [strategyType, wait, leading, trailing])
- // Memoize mutationFn to prevent recreation on every render
- const mutationFn = useCallback(
- async ({ transaction }: { transaction: Transaction }) => {
+ // Create the paced mutations hook with onMutate for optimistic updates
+ const mutate = usePacedMutations({
+ onMutate: (newValue) => {
+ // Apply optimistic update immediately
+ itemCollection.update(1, (draft) => {
+ draft.value = newValue
+ draft.timestamp = Date.now()
+ })
+ },
+ mutationFn: async ({ transaction }) => {
console.log(`mutationFn called with transaction:`, transaction)
// Update transaction state to executing when commit starts
@@ -152,25 +159,15 @@ export function App() {
// Sync back from server
serverEmitter.emit(`sync`, transaction.mutations)
},
- []
- )
-
- // Create the paced mutations hook
- const mutate = usePacedMutations({
- mutationFn,
strategy,
})
// Trigger a mutation with a specific value
const triggerMutation = (newValue: number) => {
- const tx = mutate(() => {
- itemCollection.update(1, (draft) => {
- draft.value = newValue
- draft.timestamp = Date.now()
- })
- })
+ // Pass the value directly - onMutate will apply the optimistic update
+ const tx = mutate(newValue)
- // Update optimistic state
+ // Update optimistic state after onMutate has been called
setOptimisticState(itemCollection.get(1))
// Track this transaction