Batching & Transactions
Learn how to group multiple changes into atomic operations for better undo/redo behavior and improved performance.
When to Use Batching
graph TD
A[Making changes?] -->|Single change| B[No batching needed]
A -->|Multiple changes| C{Are changes related?}
C -->|Yes, same logical operation| D[Use batch\\(\\)]
C -->|No, independent| E[Keep separate]
D --> F{How many changes?}
F -->|2-10 changes| G[batch\\(\\) for clean undo]
F -->|10-100 changes| H[batch\\(\\) for performance]
F -->|100+ changes| I[batch\\(\\) is critical!]
style D fill:#c8e6c9
style G fill:#a5d6a7
style H fill:#81c784
style I fill:#66bb6a
style E fill:#fff9c4Examples of when to batch:
- ✅ Form submission (multiple field updates)
- ✅ Bulk data import
- ✅ Complex UI interactions (drag & drop with multiple updates)
- ✅ Synchronizing related state (cart items + total + tax)
- ❌ Independent user actions (separate button clicks)
Why Batch Changes?
When you make multiple related changes, batching provides two key benefits:
1. Atomic Undo/Redo
// Without batching: 3 separate undo operations
cart.items.push({ id: 1, name: 'Apple', price: 1.50 });
cart.total = 1.50;
cart.count = 1;
chronicle.undo(cart); // Only undoes count ❌
chronicle.undo(cart); // Only undoes total ❌
chronicle.undo(cart); // Only undoes items ❌
// With batching: 1 undo operation
chronicle.batch(cart, (state) => {
state.items.push({ id: 1, name: 'Apple', price: 1.50 });
state.total = 1.50;
state.count = 1;
});
chronicle.undo(cart); // Undoes all three! ✅2. Performance Optimization
// Without batching: Listeners fire 1000 times
for (let i = 0; i < 1000; i++) {
state.items.push(i);
// Listener fires after each push 😫
}
// With batching: Listeners fire ONCE
chronicle.batch(state, (s) => {
for (let i = 0; i < 1000; i++) {
s.items.push(i);
}
// Listeners fire once after batch 🎉
});Basic Batching
The batch() Function
Group synchronous changes into one atomic operation:
import { chronicle } from '@arcmantle/chronicle';
const state = chronicle({
firstName: '',
lastName: '',
fullName: ''
});
// Batch multiple changes
chronicle.batch(state, (s) => {
s.firstName = 'John';
s.lastName = 'Doe';
s.fullName = `${s.firstName} ${s.lastName}`;
});
// One undo reverts all changes
chronicle.undo(state);
console.log(state.firstName); // ''
console.log(state.lastName); // ''
console.log(state.fullName); // ''Batch Callback Parameter
The callback receives the state object:
chronicle.batch(state, (s) => {
// 's' is the same as 'state'
s.count++;
s.lastModified = Date.now();
});
// You can also use 'state' directly
chronicle.batch(state, () => {
state.count++;
state.lastModified = Date.now();
});Prefer the Parameter
Using the callback parameter (s) => makes it clear which state is being modified and helps with nested batches.
Return Values
Batches can return values:
const result = chronicle.batch(state, (s) => {
s.items.push({ id: 1, name: 'Item 1' });
s.items.push({ id: 2, name: 'Item 2' });
return s.items.length; // Return the count
});
console.log(result); // 2Nested Batches
Batches can be nested - inner batches are merged into outer ones:
const state = chronicle({
user: { name: '', email: '' },
settings: { theme: 'light' }
});
chronicle.batch(state, () => {
state.user.name = 'Alice';
// Inner batch merges into outer
chronicle.batch(state, () => {
state.user.email = 'alice@example.com';
state.settings.theme = 'dark';
});
});
// One undo reverts all three changes
chronicle.undo(state);Visual Representation
Outer Batch Start
├─ Change: user.name = 'Alice'
├─ Inner Batch Start
│ ├─ Change: user.email = 'alice@example.com'
│ └─ Change: settings.theme = 'dark'
└─ Inner Batch End
Outer Batch End
Result: One history entry with 3 changesAsync Transactions
For asynchronous operations, use transaction():
Basic Transaction
await chronicle.transaction(state, async (s) => {
// Fetch data
const user = await fetchUser(123);
s.user = user;
// Fetch more data
const settings = await fetchSettings(user.id);
s.settings = settings;
});
// One undo reverts both changes
chronicle.undo(state);Error Handling
Transactions automatically rollback on error:
try {
await chronicle.transaction(state, async (s) => {
s.step = 1;
await saveToServer(s.data);
s.step = 2;
await sendNotification();
s.step = 3;
throw new Error('Oops!'); // Error!
});
} catch (error) {
// Transaction rolled back automatically
console.log(state.step); // 0 (original value)
}Automatic Rollback
Transactions (both sync and async) automatically rollback all changes if an error is thrown. Top-level transactions use rollbackBatch(), nested transactions use undoSince() to the marker.
Transaction vs Batch
// ✅ Use batch() for synchronous changes
chronicle.batch(state, (s) => {
s.count++;
s.total = s.count * 10;
});
// ✅ Use transaction() for async changes
await chronicle.transaction(state, async (s) => {
const data = await fetchData();
s.data = data;
s.loaded = true;
});
// ❌ Don't use batch() with async
chronicle.batch(state, async (s) => {
// Batch ends immediately, not after await!
const data = await fetchData();
s.data = data; // This is NOT in the batch!
});Listener Behavior
Deferred Notifications
Listeners don't fire until the batch completes:
chronicle.listen(state, 'count', (path, newVal) => {
console.log('Count:', newVal);
});
chronicle.batch(state, () => {
state.count = 1; // Listener doesn't fire yet
state.count = 2; // Listener doesn't fire yet
state.count = 3; // Listener doesn't fire yet
});
// Listener fires ONCE here with value 3Multiple Path Changes
If the same path changes multiple times, listeners fire with the final value:
chronicle.listen(state, 'value', (path, newVal, oldVal) => {
console.log(`${oldVal} → ${newVal}`);
});
chronicle.batch(state, () => {
state.value = 1;
state.value = 2;
state.value = 3;
});
// Logs: "0 → 3" (not 0→1, 1→2, 2→3)'down' Mode Listeners
Listeners in 'down' mode fire once for each changed descendant:
chronicle.listen(state, 'user', (path) => {
console.log('Changed:', path.join('.'));
}, 'down');
chronicle.batch(state, () => {
state.user.name = 'Alice';
state.user.email = 'alice@example.com';
state.user.age = 30;
});
// Fires 3 times:
// "Changed: user.name"
// "Changed: user.email"
// "Changed: user.age"Common Patterns
Pattern 1: Form Submission
async function submitForm(state: FormState) {
await chronicle.transaction(state, async (s) => {
s.submitting = true;
s.error = null;
try {
const result = await api.submitForm({
name: s.name,
email: s.email,
message: s.message
});
s.submitted = true;
s.submitId = result.id;
} catch (error) {
s.error = error.message;
throw error; // Rollback
} finally {
s.submitting = false;
}
});
}Pattern 2: Multi-Step Wizard
function completeWizard(state: WizardState) {
chronicle.batch(state, (s) => {
s.completed = true;
s.completedAt = Date.now();
s.currentStep = s.steps.length;
// Compute summary
s.summary = {
duration: s.completedAt - s.startedAt,
steps: s.steps.length,
data: gatherData(s)
};
});
}Pattern 3: Bulk Operations
function deleteSelectedItems(state: AppState, selectedIds: number[]) {
chronicle.batch(state, (s) => {
s.items = s.items.filter(item => !selectedIds.includes(item.id));
s.selectedCount = 0;
s.lastAction = 'bulk-delete';
s.deletedCount += selectedIds.length;
});
}Pattern 4: Computed Properties
function updateCartItem(cart: Cart, itemId: number, quantity: number) {
chronicle.batch(cart, (c) => {
const item = c.items.find(i => i.id === itemId);
if (!item) return;
item.quantity = quantity;
// Recompute totals
c.subtotal = c.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
c.tax = c.subtotal * c.taxRate;
c.total = c.subtotal + c.tax;
});
}Pattern 5: State Synchronization
async function syncFromServer(state: AppState) {
const data = await fetchFromServer();
chronicle.batch(state, (s) => {
// Update multiple parts of state
s.users = data.users;
s.posts = data.posts;
s.comments = data.comments;
s.lastSync = Date.now();
s.syncStatus = 'complete';
});
}Manual Batch Control
For advanced use cases, manually control batching:
Begin, Commit, and Rollback
// Start a batch
chronicle.beginBatch(state);
try {
state.step = 1;
await doSomething();
state.step = 2;
await doSomethingElse();
state.step = 3;
// Commit if successful
chronicle.commitBatch(state);
} catch (error) {
// Rollback on error
chronicle.rollbackBatch(state);
throw error;
}
// One undoGroups(1) reverts all changes in the batch
chronicle.undoGroups(state, 1);Conditional Batching
const shouldBatch = items.length > 10;
if (shouldBatch) {
chronicle.beginBatch(state);
}
for (const item of items) {
state.items.push(item);
state.count++;
}
if (shouldBatch) {
chronicle.commitBatch(state);
}Prefer batch() and transaction()
Manual batch control is rarely needed. Use batch() or transaction() for most cases.
Auto-Batching Configuration
Configure Chronicle to automatically batch rapid changes:
chronicle.configure(state, {
mergeUngrouped: true, // Enable auto-batching
mergeWindowMs: 100 // Merge changes within 100ms
});
// Rapid changes are automatically grouped
state.searchQuery = 'h';
state.searchQuery = 'he';
state.searchQuery = 'hel';
state.searchQuery = 'hello';
// After 100ms, all changes merge into one undoHow It Works
└─ h └─ he └─ hel └─hello
Wait 100ms after last change...
Result: One history entry with all 4 changesWhen to Use Auto-Batching
Good use cases:
- Search inputs
- Sliders and range inputs
- Real-time text editing
- Canvas drawing
Avoid for:
- User-triggered actions (button clicks)
- API responses
- Important state transitions
Batching Best Practices
✅ Do: Group Related Changes
// ✅ Good: Related changes grouped
chronicle.batch(state, (s) => {
s.user.name = newName;
s.user.updatedAt = Date.now();
s.user.updatedBy = currentUser;
});❌ Don't: Batch Unrelated Changes
// ❌ Bad: Unrelated changes grouped
chronicle.batch(state, (s) => {
s.user.name = 'Alice';
s.theme = 'dark';
s.language = 'en';
// These aren't related!
});✅ Do: Use Batching for Performance
// ✅ Good: Batch for performance
chronicle.batch(state, (s) => {
for (let i = 0; i < 1000; i++) {
s.items.push({ id: i, value: Math.random() });
}
});❌ Don't: Batch Single Changes
// ❌ Bad: Unnecessary batch
chronicle.batch(state, (s) => {
s.count++; // Just do: state.count++
});✅ Do: Return Values from Batches
// ✅ Good: Return computed result
const success = chronicle.batch(state, (s) => {
if (!validate(s)) return false;
s.submitted = true;
s.submittedAt = Date.now();
return true;
});✅ Do: Use Transactions for Async
// ✅ Good: Transaction for async
await chronicle.transaction(state, async (s) => {
const data = await fetchData();
s.data = data;
s.loaded = true;
});Performance Impact
Batching Overhead
Batching has minimal overhead:
// Without batching: ~1000 operations
for (let i = 0; i < 1000; i++) {
state.items.push(i);
}
// With batching: ~1 operation + batch overhead
chronicle.batch(state, (s) => {
for (let i = 0; i < 1000; i++) {
s.items.push(i);
}
});Benchmark results:
- Single change: ~0.01ms
- Batched 1000 changes: ~0.5ms (50x faster than individual)
- Listener calls reduced from 1000 to 1
Memory Benefits
// Without batching: 1000 history entries
for (let i = 0; i < 1000; i++) {
state.value = i;
}
// Memory: ~50KB
// With batching: 1 history entry
chronicle.batch(state, (s) => {
for (let i = 0; i < 1000; i++) {
s.value = i;
}
});
// Memory: ~50 bytes (1000x less!)Debugging Batches
Log Batch Operations
const originalBatch = chronicle.batch;
chronicle.batch = function(state, callback) {
console.log('[Batch] Start');
const result = originalBatch(state, callback);
console.log('[Batch] End');
return result;
};Track Batch Depth
let batchDepth = 0;
chronicle.batch = function(state, callback) {
batchDepth++;
console.log(`[Batch] Depth: ${batchDepth}`);
try {
return originalBatch(state, callback);
} finally {
batchDepth--;
}
};Measure Batch Performance
function timedBatch<T>(state: T, callback: (state: T) => void) {
const start = performance.now();
const result = chronicle.batch(state, callback);
const end = performance.now();
console.log(`Batch took ${(end - start).toFixed(2)}ms`);
return result;
}Next Steps
Now that you understand batching, learn about state snapshots and comparisons:
- Snapshots & Diffs → - Compare and restore states
- Performance → - Optimize batching strategies
- Best Practices → - Architectural patterns
- API Reference → - Complete API documentation
Ready for snapshots? Continue to Snapshots & Diffs to learn about state comparisons.