Skip to content

batch()

Batch multiple signal updates so effects only run once.

function batch(fn: () => void): void
ParamTypeDescription
fn() => voidFunction containing multiple signal writes
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 ONCE

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).

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)

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=2

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";
});

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.

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 Set and 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.