DEV Community

Cover image for Why Batch Belongs to the Scheduler, Not Computed
Luciano0322
Luciano0322

Posted on

Why Batch Belongs to the Scheduler, Not Computed

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, and computed recomputes lazily on get())

Why Do We Need batch?

A key design principle here is:

computed stays lazy.
batch / transaction only adjust effect scheduling (push merging).
They never force eager recomputation.

In other words:

  • computed decides when to compute
  • batch decides when side effects run

Design Approach

We modularize scheduling logic into a dedicated scheduler module to handle:

  • Deduplication
  • Microtask merging
  • Batching
  • flushSync support

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 to batch(fn) (introduced mainly for testing; extensible later)
  • effect: only one-line change schedule()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");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

No changes are required for:

  • signal.set()
    • still:
      • schedules effects
      • marks computed values as stale
  • computed.get()
    • still recomputes lazily on demand

Batch & Transaction Semantics

batch(fn)

  • Inside a batch:
    • scheduleJob only 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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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

  • computed controls when values are calculated (lazy evaluation)
  • batch / transaction control 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)