[]previous and next page

When a write reaches effects and the DOM

Signal writes and store writes follow the same scheduler rule (ADR-007).

For agentsUpdated

Signal writes and store writes follow the same scheduler rule (ADR-007). A write marks its readers stale at once; reads always see the new value. Effects, including the render effects that update the DOM, run in a microtask unless something flushes earlier:

Where the write happens When effects run
Plain code (a timer, a promise callback, module code) In a microtask. All writes made in the same tick share one run.
Inside batch(fn) Synchronously, once, when the outermost batch ends.
Inside an event handler bound by JSX Synchronously, once, after all handlers for that event have run.
flushSync() or flushSync(fn) Synchronously, now.
src/timing.tsts
import { batch, createRoot, flushSync, useEffect, useStore } from "@shadow-js/core";

const [count, setCount] = useStore(0);
const [form, setForm] = useStore({ name: "Ada" });
const log: string[] = [];

createRoot(() => {
  useEffect(() => {
    log.push(`effect: ${count()} ${form.name}`);
  });
});
flushSync();
console.log(log.length); // 1: the first run

setCount(1); // a signal write
setForm("name", "Grace"); // a store write
console.log(log.length); // 1 again: nothing has run yet
await Promise.resolve();
console.log(log.length); // 2: one run covered both writes

batch(() => {
  setCount(2);
  setForm("name", "Hopper");
});
console.log(log.length); // 3: the batch flushed on exit

setCount(3);
flushSync();
console.log(log.length); // 4
console.log(log.join("; "));
Expected output
1
1
2
3
4
effect: 0 Ada; effect: 1 Grace; effect: 2 Hopper; effect: 3 Hopper

Consequences:

  • Reading state right after writing it always gives the new value. Reading the DOM right after writing does not, unless you are in an event handler, a batch or after flushSync().

  • A write inside a useMemo computation throws WRITE_IN_MEMO in development; see Derived values.