Commit cb25623
feat: Add paced mutations with timing strategies (#704)
* feat: add useSerializedMutations hook with timing strategies
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 <[email protected]>
* Fix feedback-4 issues and add interactive demo
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 <[email protected]>
* fix: serialized mutations strategy execution and transaction handling
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)
* prettier
* test: add comprehensive tests for queue and throttle strategies
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 <[email protected]>
* fix: resolve TypeScript strict mode errors in useSerializedMutations 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 <[email protected]>
* refactor: convert demo to slider-based interface with 300ms default
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 <[email protected]>
* fix(demo): improve UI and fix slider reset issue
- 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 <[email protected]>
* fix(queue): capture transaction before clearing activeTransaction
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 <[email protected]>
* fix(queue): explicitly default to FIFO processing order
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 <[email protected]>
* docs: clarify queue strategy creates separate transactions with configurable 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 <[email protected]>
* refactor: rename "Serialized Mutations" to "Paced Mutations"
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 <[email protected]>
* update lock
* chore: change paced mutations changeset from minor to patch
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <[email protected]>
* fix: update remaining references to useSerializedMutations
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 <[email protected]>
* docs: mention TanStack Pacer in changeset
Add reference to TanStack Pacer which powers the paced mutations strategies.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <[email protected]>
* docs: clarify key design difference between strategies
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 <[email protected]>
* docs: add comprehensive Paced Mutations guide
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 <[email protected]>
* fix: remove id property from PacedMutationsConfig
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 <[email protected]>
* fix: prevent unnecessary recreation of paced mutations instance
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 <[email protected]>
* test: add memoization tests for usePacedMutations
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 <[email protected]>
* fix: stabilize mutationFn to prevent recreating paced mutations instance
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 <[email protected]>
* Refactor paced mutations to work like createOptimisticAction
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 <[email protected]>
* Update paced mutations demo to use new onMutate API
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 <[email protected]>
---------
Co-authored-by: Claude <[email protected]>1 parent d2b569c commit cb25623
File tree
30 files changed
+2954
-32
lines changed- .changeset
- docs/guides
- examples/react
- paced-mutations-demo
- src
- todo/src
- components
- routes
- packages
- db
- src
- strategies
- react-db
- src
- tests
30 files changed
+2954
-32
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
0 commit comments