Enhanced Batching and Priority Queue
What is Enhanced Batching?
The enhanced batching system adds a priority-based update queue to the reactive system. Instead of running effects in whatever order they were registered, updates are now scheduled by priority: computed properties run first, then watchers, then regular effects.
It also includes infinite loop detection to catch situations where an effect modifies state that triggers itself.
Why Priority Matters
Consider this scenario:
const state = state({ price: 100, taxRate: 0.2 });
computed(state, {
total: function() {
return this.price + (this.price * this.taxRate);
}
});
effect(() => {
Elements.total.update({ textContent: `$${state.total}` });
});When state.price changes, two things need to happen:
- The
totalcomputed property needs to recalculate - The effect needs to re-render the UI
The order matters:
❌ Without priority (unpredictable order):
state.price = 200
↓
Effect runs FIRST → reads state.total → gets STALE value (120)
↓
Computed recalculates → total is now 240
↓
UI shows wrong value!
✅ With priority queue:
state.price = 200
↓
Priority 1 (COMPUTED): total recalculates → 240
↓
Priority 2 (WATCH): watchers run with correct values
↓
Priority 3 (EFFECT): effect reads state.total → gets 240 ✅
↓
UI shows correct value!The Priority Levels
const PRIORITY = {
COMPUTED: 1, // Run first — computed properties
WATCH: 2, // Run second — watchers
EFFECT: 3 // Run last — regular effects
};Update Queue:
┌─────────────────────────────────────────┐
│ Priority 1 (COMPUTED) │
│ ├── total recalculation │
│ └── other computed properties │
├─────────────────────────────────────────┤
│ Priority 2 (WATCH) │
│ ├── watch(state, 'price', ...) │
│ └── other watchers │
├─────────────────────────────────────────┤
│ Priority 3 (EFFECT) │
│ ├── UI rendering effects │
│ └── side-effect functions │
└─────────────────────────────────────────┘
↑
Processed top-to-bottomHow the Queue Works
Step-by-Step
const state = state({ count: 0 });
computed(state, {
doubled: function() { return this.count * 2; }
});
watch(state, 'count', (n) => console.log('Watched:', n));
effect(() => {
console.log('Effect:', state.doubled);
});
state.count = 5;What happens when state.count = 5:
1️⃣ state.count = 5 (proxy set trap fires)
↓
2️⃣ queueUpdate() is called for each dependent:
├── 'doubled' computed → added to queue at Priority 1
├── watch callback → added to queue at Priority 2
└── effect function → added to queue at Priority 3
↓
3️⃣ isFlushPending is false → schedule flush via queueMicrotask
↓
4️⃣ Current synchronous code finishes
↓
5️⃣ Microtask runs → flushQueue():
├── Sort priorities: [1, 2, 3]
├── Run Priority 1: computed 'doubled' recalculates → 10
├── Run Priority 2: watcher logs "Watched: 5"
└── Run Priority 3: effect logs "Effect: 10"
↓
6️⃣ Queue is empty → flushCount reset to 0queueMicrotask — Why Not setTimeout?
The queue uses queueMicrotask() instead of setTimeout():
JavaScript Event Loop:
┌──────────────────────────────────────┐
│ 1. Synchronous code │
│ state.count = 5 │
│ state.name = 'Alice' │
├──────────────────────────────────────┤
│ 2. Microtasks (queueMicrotask) │ ← Queue flushes HERE
│ All queued updates run │
│ Before any rendering │
├──────────────────────────────────────┤
│ 3. Rendering │
│ Browser paints the screen │
├──────────────────────────────────────┤
│ 4. Macrotasks (setTimeout) │
│ Too late — screen already painted │
└──────────────────────────────────────┘By using queueMicrotask, all updates are processed before the browser paints. This means the UI always shows the final, consistent state — never an intermediate one.
Infinite Loop Detection
If an effect modifies state that triggers itself, it creates an infinite loop. The queue detects this:
const state = state({ count: 0 });
// ❌ This effect modifies the state it depends on
effect(() => {
state.count = state.count + 1; // Reads count, then sets count
// This triggers itself, which triggers itself, which...
});
// After 100 flushes, the system stops and logs:
// [Enhancements] Infinite update loop detected.
// An effect may be modifying state that triggers itself.How it works:
flushQueue() called
↓
flushCount++ (now 1)
↓
Run effects → effect modifies state → queues new updates
↓
flushQueue() called again
↓
flushCount++ (now 2)
↓
...repeats...
↓
flushCount reaches 100
↓
STOP! Clear the queue, reset counter, log error.The MAX_FLUSH_COUNT is set to 100 — more than enough for legitimate cascading updates, but catches true infinite loops.
Using the Enhanced Batch
The enhancedBatch function wraps the existing batch:
ReactiveEnhancements.batch(() => {
state.x = 1;
state.y = 2;
state.z = 3;
// Effects run once after all three assignments
});This works the same as the original batch(), but within the enhanced queueing system.
Using queueUpdate Directly (Advanced)
For advanced use cases, you can manually queue updates with a specific priority:
const { queueUpdate, PRIORITY } = ReactiveEnhancements;
// Queue something to run at computed priority (first)
queueUpdate(() => {
console.log('This runs first');
}, PRIORITY.COMPUTED);
// Queue something to run at effect priority (last)
queueUpdate(() => {
console.log('This runs last');
}, PRIORITY.EFFECT);
// Queue something at watcher priority (middle)
queueUpdate(() => {
console.log('This runs second');
}, PRIORITY.WATCH);Common Mistakes
❌ Modifying state inside an effect that reads it
// ❌ Creates an infinite loop — caught after 100 iterations
effect(() => {
state.count++; // Reads AND writes count
});
// ✅ If you need to derive a value, use a computed property
computed(state, {
doubled: function() { return this.count * 2; } // Only reads — never writes
});❌ Assuming effects run synchronously
state.count = 5;
// ❌ The effect hasn't run yet — it's queued as a microtask
console.log(document.getElementById('output').textContent);
// May still show old value
// ✅ If you need to run something after effects flush,
// use queueMicrotask or await a tick:
state.count = 5;
queueMicrotask(() => {
console.log(document.getElementById('output').textContent);
// Now it shows the updated value
});Key Takeaways
- Priority queue: Computed (1) → Watch (2) → Effect (3) — guaranteed order
- queueMicrotask: Updates flush before the browser paints
- Infinite loop detection: Stops after 100 flushes with a clear error message
- Consistent state: Effects always see fully-updated computed values
- Batching: Multiple state changes in the same tick are grouped into one flush
What's next?
Let's explore deep reactivity for Map and Set collections.
Let's continue!