Batching & Untracked Reads

The Problem

By default, every signal.value = x immediately notifies all subscribers and updates all bound DOM nodes. This is fine for single updates, but problematic when you change multiple signals at once:

typescriptconst firstName = signal('John');
const lastName = signal('Doe');

effect(() => {
  console.log(`${firstName.value} ${lastName.value}`);
});

// Without batch: logs "Jane Doe" then "Jane Smith" (two runs)
firstName.value = 'Jane';
lastName.value = 'Smith';

The effect sees an intermediate state (Jane Doe) that was never intended. Worse, if these signals are bound to DOM nodes, the browser recalculates layout between each mutation — causing layout thrashing.

batch()

batch() defers all signal notifications until the batch completes, then flushes them in one pass:

typescriptimport { batch } from '@cmj/cellui';

// With batch: logs only "Jane Smith" (one run)
batch(() => {
  firstName.value = 'Jane';
  lastName.value = 'Smith';
});

How It Works

  1. When a batch is active, signal.value = x stores the new value but skips notification
  2. When the outermost batch exits, all changed signals flush their subscribers once
  3. If a shared subscriber (like an effect) is subscribed to multiple changed signals, it runs exactly once
  4. If a signal's value reverted to its pre-batch original, no notification fires at all

Deduplication

typescriptconst count = signal(0);
count.subscribe(v => console.log(v));

batch(() => {
  count.value = 1;
  count.value = 2;
  count.value = 0; // back to original
});
// Nothing logged — the final value equals the original

Nested Batches

Inner batches don't flush. The outermost batch controls timing:

typescriptbatch(() => {
  a.value = 1;

  batch(() => {
    b.value = 2;
  });
  // NOT flushed yet — outer batch still open

  c.value = 3;
});
// All three flush here

Return Values

batch() returns whatever the callback returns:

typescriptconst result = batch(() => {
  count.value++;
  return count.value;
});
// result === 1

Error Handling

If the callback throws, the batch still flushes:

typescripttry {
  batch(() => {
    count.value = 5;
    throw new Error('oops');
  });
} catch (e) {
  // count's subscribers were notified with value 5
}

DOM Performance

Without batch, updating 10 signals bound to DOM nodes causes 10 separate DOM mutations with potential layout recalculations between each one. With batch, all 10 DOM updates happen in a single pass:

typescript// Bad: 10 DOM writes, potential layout thrashing
for (const sig of signals) {
  sig.value = newValues[sig];
}

// Good: 1 flush pass, 10 DOM writes in sequence
batch(() => {
  for (const sig of signals) {
    sig.value = newValues[sig];
  }
});

untracked()

Inside an effect(), reading a signal's .value automatically subscribes the effect to that signal. untracked() suppresses this — the signal is read but no dependency is recorded:

typescriptimport { untracked } from '@cmj/cellui';

const data = signal([1, 2, 3]);
const config = signal({ pageSize: 10 });

effect(() => {
  // This effect re-runs when `data` changes
  const items = data.value;

  // But NOT when `config` changes
  const cfg = untracked(() => config.value);

  render(items.slice(0, cfg.pageSize));
});

When to Use untracked()

  • Reading configuration that rarely changes and shouldn't trigger re-computation
  • Accessing ambient/global state without creating a dependency
  • Breaking circular dependencies where two effects would otherwise trigger each other
  • Performance optimization when you know a signal won't affect the output

untracked() Returns the Value

typescriptconst val = untracked(() => count.value);
// val is the current value of count

Combining batch() and untracked()

typescripteffect(() => {
  const items = data.value;  // tracked
  const config = untracked(() => appConfig.value);  // not tracked

  batch(() => {
    // Update multiple derived signals without intermediate notifications
    filteredItems.value = items.filter(config.filter);
    itemCount.value = filteredItems.value.length;
    isEmpty.value = itemCount.value === 0;
  });
});

API Reference

batch<T>(fn: () => T): T

Group signal updates into a single notification pass.

Parameter Type Description
fn () => T Callback containing signal updates
Returns T The return value of the callback

untracked<T>(fn: () => T): T

Read signals without creating subscriptions.

Parameter Type Description
fn () => T Callback that reads signal values
Returns T The return value of the callback