Recap
Building on the previous articles—signal, effect, and computed—we have already made the timing of side-effect execution much more controllable:
-
Multiple
set()calls are merged → an effect reruns only once per flush cycle - Updating multiple signals in one block → avoids intermediate “flickering” states
-
Reading inside a block always returns the latest value
(because
set()writes synchronously, andcomputedrecomputes lazily onget())
Why Do We Need batch?
A key design principle here is:
computedstays lazy.
batch/transactiononly adjust effect scheduling (push merging).
They never force eager recomputation.
In other words:
-
computeddecides when to compute -
batchdecides when side effects run
Design Approach
We modularize scheduling logic into a dedicated scheduler module to handle:
- Deduplication
- Microtask merging
- Batching
flushSyncsupport
Components
-
scheduler.ts: A minimal scheduler (dedupe, microtask merging, batch / flushSync support) -
batch(fn): Wraps a sequence of updates and flushes effects at the end -
transaction(fn): Currently identical tobatch(fn)(introduced mainly for testing; extensible later) -
effect: only one-line changeschedule()→scheduleJob(this)
Extracting the Scheduler
Previously, we embedded Set + queueMicrotask + flush logic directly inside EffectInstance.
Now we extract it into a standalone module for clearer semantics and easier extensibility.
// scheduler.ts
export interface Schedulable {
run(): void;
disposed?: boolean;
}
const queue = new Set<Schedulable>();
let scheduled = false;
let batchDepth = 0;
// Add a job to the queue.
// If not inside a batch, schedule a microtask flush.
export function scheduleJob(job: Schedulable) {
if (job.disposed) return;
queue.add(job);
if (!scheduled && batchDepth === 0) {
scheduled = true;
queueMicrotask(flushJobs);
}
}
// Merge multiple updates into a single effect flush
export function batch<T>(fn: () => T): T {
batchDepth++;
try {
return fn();
} finally {
batchDepth--;
if (batchDepth === 0) flushJobs();
}
}
// Immediately flush the queue (useful for tests)
export function flushSync() {
if (!scheduled && queue.size === 0) return;
flushJobs();
}
function flushJobs() {
scheduled = false;
let guard = 0;
while (queue.size) {
const list = Array.from(queue);
queue.clear();
for (const job of list) job.run();
if (++guard > 10000) {
throw new Error("Infinite update loop");
}
}
}
Effect Integration (One-Line Change)
// effect.ts
import { scheduleJob } from "./scheduler";
export class EffectInstance {
/* existing members and run() unchanged */
schedule() {
scheduleJob(this); // previously: push into Set + queueMicrotask
}
}
No changes are required for:
-
signal.set()- still:
- schedules effects
- marks computed values as stale
- still:
-
computed.get()- still recomputes lazily on demand
Batch & Transaction Semantics
batch(fn)
- Inside a batch:
-
scheduleJobonly queues effects - no microtask is scheduled
-
- When exiting the outermost batch:
-
flushJobs()runs once
-
- Nested batches still flush only once
import { batch } from "./scheduler";
batch(() => {
a.set(10);
b.set(20);
a.set(30);
});
// effect runs only once
transaction(fn)
Current semantics:
- Identical to
batch(fn) - Effects are merged
-
signal.set()is still synchronous - Reads inside the block see the latest state
At the moment, transaction mainly exists to make experiments and testing clearer.
Later, it can be extended with logging, rollback, or other advanced capabilities.
import { batch } from "./scheduler";
// currently identical to batch; reserved for future upgrades
export function transaction<T>(fn: () => T): T {
return batch(fn);
}
Example: Effect Runs Only Once
const a = signal(1);
const b = signal(2);
const stop = createEffect(() => {
console.log("sum =", a.get() + b.get());
});
batch(() => {
a.set(10);
b.set(20);
a.set(30);
});
// logs once: sum = 50
stop();
Example: Interaction with computed
const a = signal(1);
const b = signal(2);
const sum = computed(() => a.get() + b.get());
const stop = createEffect(() => {
console.log("double =", sum.get() * 2);
});
transaction(() => {
a.set(5);
b.set(7);
// Reading inside the transaction:
// computed recomputes lazily when needed
console.log("peek in tx =", sum.get() * 2); // 24
});
// microtask:
// effect runs once → "double = 24"
stop();
Batch Timeline
Merging Multiple set() Calls
Batch Data Flow
Common Pitfalls
Repeatedly setting the same signal inside a batch
- The last write wins
- This is expected behavior
- If needed, you can deduplicate values before calling
set()
Doing heavy synchronous work inside a batch
- Microtasks are scheduled only after the batch ends
- Long synchronous work delays UI updates
Want to immediately observe effect execution?
- Use
flushSync()to force an immediate flush
Conclusion
-
computedcontrols when values are calculated (lazy evaluation) -
batch/transactioncontrol when side effects run (merged at the end)
Once these responsibilities are clearly separated, your reactive system becomes both stable and smooth in practice.
At this point, the system we’ve built is already quite complete.
Experienced readers should be able to see how it can be transplanted into their framework of choice—the core concepts are all here.
In the next article, we’ll try running this system inside a React environment, and discuss some of the limitations imposed by framework-level rendering.



Top comments (0)