-
Notifications
You must be signed in to change notification settings - Fork 107
feat: Add paced mutations with timing strategies #704
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Implements a new hook for managing optimistic mutations with pluggable timing strategies (debounce, queue, throttle) using TanStack Pacer.
Key features:
- Auto-merge mutations and serialize persistence according to strategy
- Track and rollback superseded pending transactions to prevent memory leaks
- Proper cleanup of pending/executing transactions on unmount
- Queue strategy uses AsyncQueuer for true sequential processing
Breaking changes from initial design:
- Renamed from useSerializedTransaction to useSerializedMutations (more accurate name)
- Each mutate() call creates mutations that are auto-merged, not separate transactions
Addresses feedback:
- HIGH: Rollback superseded transactions to prevent orphaned isPersisted promises
- HIGH: cleanup() now properly rolls back all pending/executing transactions
- HIGH: Queue strategy properly serializes commits using AsyncQueuer with concurrency: 1
Example usage:
```tsx
const mutate = useSerializedMutations({
mutationFn: async ({ transaction }) => {
await api.save(transaction.mutations)
},
strategy: debounceStrategy({ wait: 500 })
})
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Fixes for feedback-4 issues: - Queue strategy: await isPersisted.promise instead of calling commit() again to fix double-commit error - cleanup(): check transaction state before rollback to prevent errors on completed transactions - Pending transactions: rollback all pending transactions on each new mutate() call to handle dropped callbacks Added interactive serialized mutations demo: - Visual tracking of transaction states (pending/executing/completed/failed) - Live configuration of debounce/queue/throttle strategies - Real-time stats dashboard showing transaction counts - Transaction timeline with mutation details and durations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Core fixes:
- Save transaction reference before calling strategy.execute() to prevent null returns when strategies (like queue) execute
callbacks synchronously
- Call strategy.execute() on every mutate() call to properly reset debounce timers
- Simplified transaction lifecycle - single active transaction that gets reused for batching
Demo improvements:
- Memoized strategy and mutationFn to prevent unnecessary recreations
- Added fake server sync to demonstrate optimistic updates
- Enhanced UI to show optimistic vs synced state and detailed timing
- Added mitt for event-based server communication
Tests:
- Replaced comprehensive test suite with focused debounce strategy tests
- Two tests demonstrating batching and timer reset behavior
- Tests pass with real timers and validate mutation auto-merging
🤖 Generated with [Claude Code](https://claude.com/claude-code)
🦋 Changeset detectedLatest commit: 7b4e55a The changes in this PR will be included in the next version bump. This PR includes changesets to release 13 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
More templates
@tanstack/angular-db
@tanstack/db
@tanstack/db-ivm
@tanstack/electric-db-collection
@tanstack/query-db-collection
@tanstack/react-db
@tanstack/rxdb-db-collection
@tanstack/solid-db
@tanstack/svelte-db
@tanstack/trailbase-db-collection
@tanstack/vue-db
commit: |
|
Size Change: +1.53 kB (+1.81%) Total Size: 85.8 kB
ℹ️ View Unchanged
|
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 <noreply@anthropic.com>
|
Size Change: 0 B Total Size: 2.89 kB ℹ️ View Unchanged
|
…tests 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- Use mutation.modified instead of mutation.changes for updates to preserve full state - Remove Delta stat card as it wasn't providing value - Show newest transactions first in timeline for better UX 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…gurable order 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
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 <noreply@anthropic.com>
Add reference to TanStack Pacer which powers the paced mutations strategies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this a lot, we're nearly there I think.
My final question is on the queued mutation, the queue is scoped to a single mutation function, but I suspect in reality there will be multiple mutation functions doing different things: create an issue, then create a comment. I may have missed it but does this support that?
Maybe the queue options can take a key, or a queue instance?
I think it could also be worth being more explicit in the docs how the queue version is different than normal mutations - maybe explain in the docs that normal mutations are not queued, then cross linking to the queued version.
Yeah each hook creates a separate mutator — this is the same as the So e.g. const saveComment = usePacedMutatioin(...)
// in an onChange for the textarea
onChange={(e) => saveComment(e.value)} |
|
Re docs — this is what I put there — seems clear enough? Key DesignThe 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. |
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 <noreply@anthropic.com>
Summary
This PR introduces a new paced mutations system for TanStack DB, enabling optimistic mutations with pluggable timing strategies. This provides a powerful way to control when and how mutations are persisted to the backend.
Powered by TanStack Pacer, which provides the underlying debounce, throttle, and queue implementations.
paced-mutations-demo.mp4
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).
Key Features
usePacedMutationsfor easy integration in React appsExample Usage
Debounce Strategy (Auto-save)
Throttle Strategy (Volume Slider)
Queue Strategy (Sequential Processing)
Available Strategies
Core Implementation
Paced Mutations (
packages/db/src/paced-mutations.ts)autoCommit: falsefor strategy controlmutate()call. Each transaction is queued and processed in order.commit()is called via callbackStrategies (
packages/db/src/strategies/)React Hook (
packages/react-db/src/usePacedMutations.ts)Demo Application
Interactive demo at
examples/react/paced-mutations-demo/showcasing:Tests
Comprehensive test suite for all strategies:
usePacedMutationshookBug Fixes
Use Cases
Debounce Strategy:
Queue Strategy:
Throttle Strategy:
🤖 Generated with Claude Code