batch()
Batch multiple signal updates so effects only run once.
Signature
Section titled “Signature”function batch(fn: () => void): voidParameters
Section titled “Parameters”| Param | Type | Description |
|---|---|---|
fn | () => void | Function containing multiple signal writes |
Examples
Section titled “Examples”import { signal, effect, batch } from "@whisq/core";
const x = signal(0);const y = signal(0);
effect(() => console.log(x.value, y.value));// Logs: 0, 0
batch(() => { x.value = 1; y.value = 2;});// Logs: 1, 2 — only ONCEGuarantees
Section titled “Guarantees”These four behaviors are pinned by tests in @whisq/core and won’t change without a version bump. The full rationale — including which lines of reactive.ts implement each — lives in packages/core/docs/batch-semantics.md in the framework repo.
The snippets below assume import { signal, computed, effect, batch } from "@whisq/core"; at the top of the file (same as the Examples section above).
Computeds don’t recompute mid-batch
Section titled “Computeds don’t recompute mid-batch”A computed() whose dependency is written inside a batch stays stale for the remainder of the batch. It recomputes exactly once after the batch exits, no matter how many times its dependencies were written.
const a = signal(1);const b = signal(2);const sum = computed(() => a.value + b.value);
sum.value; // 3 (primes the cache)
batch(() => { a.value = 10; b.value = 20; sum.value; // 3 — STALE, still the cached value});
sum.value; // 30 (fresh)Nested batches are reference-counted
Section titled “Nested batches are reference-counted”Effects flush only at the outermost boundary. Inner batch() calls exit without running effects.
batch(() => { a.value = 1; batch(() => { b.value = 2; // no flush here }); // no flush here either});// one flush here — effects see a=1, b=2await closes the batch
Section titled “await closes the batch”batch(fn) is synchronous. Writes after the first await in an async function run outside the batch — the finally block flushes effects the instant fn() returns its pending Promise.
batch(async () => { a.value = 1; // inside the batch — deferred await fetch("/x"); b.value = 2; // OUTSIDE — runs its effect immediately});For async “transactions,” do the async work first and batch only the synchronous write cluster (assume the snippet runs inside an async function):
const data = await fetch("/x").then((r) => r.json());batch(() => { user.value = data.user; settings.value = data.settings; status.value = "ready";});A throw commits the writes so far
Section titled “A throw commits the writes so far”Signal writes inside batch() are eager — only the effect runs are deferred. If code throws mid-batch, prior writes stay committed, effects flush in finally for those writes, and the error propagates out.
try { batch(() => { a.value = 10; // committed throw new Error("oops"); b.value = 20; // unreachable });} catch {}
a.value; // 10 (committed)b.value; // unchanged (assignment never ran)// The effect reading `a` ran once after the throw, with a.value === 10.There’s no rollback — don’t use batch() as a transaction.
Not guaranteed
Section titled “Not guaranteed”These are implementation details that work today but may change without a version bump:
- Effect execution order within a flush. Effects are enqueued in a
Setand iterated in insertion order per today’s ES spec, but this may change as the runtime evolves (priority queues, component-boundary batching). - Optimizations beyond dedup. A single effect queued multiple times inside a batch runs exactly once at flush — that’s guaranteed. Beyond that (e.g., skipping effects whose deps ended the batch with their original values), no promises.