REFERENCE / CORE API

effect()

Runs fn now, then re-runs it whenever anything it read changes. Templates are built on this: every dynamic hole is one small effect.

Signature

TS
effect(fn: () => unknown): () => void

Parameters

PARAMETERTYPEDESCRIPTION
fn () => unknown The reaction. Its dependencies are whatever it reads through .value during a run — tracked fresh on every run, so a branch that stops reading a signal stops depending on it.

Returns

A dispose function. Calling it runs the effect's cleanups and detaches it for good. Effects created inside a component or another effect are disposed with their owner, so you rarely call it by hand.

Run now, re-run later

JS
const name = signal('world');

const stop = effect(() => {
  document.title = `hello, ${name.value}`;   // runs now, and again on every change of name
});

name.value = 'amo';   // the title updates in a microtask
stop();               // dispose: never runs again

Batched, verified on wake

Writes do not run effects synchronously. A write marks its readers and schedules one flush in a microtask; however many signals change in the meantime, each effect runs at most once. On wake an effect first checks whether its dependencies really changed value — a computed that recomputed to the same result keeps it asleep. flushSync() runs the queue now.

Ownership and cleanup

An effect belongs to the scope that created it — a component, a parent effect, or a root(). Each re-run first disposes everything the previous run created, which is how a conditional branch tears itself down. onCleanup() inside the effect registers work for that moment.

JS
effect(() => {
  const id = setInterval(tickFn, delay.value);
  onCleanup(() => clearInterval(id));   // runs before every re-run, and once more on dispose
});

Writing from inside

An effect may write signals it does not read. Reading and writing the same signal in one effect subscribes it to its own write; use .peek() for the read.

JS
const log = signal([]);

effect(() => {
  // reading log.value here would subscribe this effect to its own write → a loop
  log.value = [...log.peek(), `seen: ${input.value}`];
});

Errors

A throwing effect does not strand the rest of the queue. The flush runs to the end, then re-throws — one error as itself, several as an AggregateError — so it reaches the platform's uncaught-error handler exactly as a throwing listener would.

Try it

src/islands/effect.ts
import { signal, effect, html, mount } from '@amojs.dev/core';

const name = signal('world');
const log = signal<string[]>([]);

// runs once now, then again whenever `name` changes — and only then
effect(() => {
  log.value = [...log.peek(), `effect ran: hello, ${name.value}`];
});

const Demo = () => html`<div class="demo">
  <div class="row">
    <input aria-label="name" value="${name}" oninput=${(e: Event) => (name.value = (e.target as HTMLInputElement).value)} />
    <button class="counter" onclick=${() => (name.value = 'world')}>set "world" again</button>
  </div>
  <p>writing the value it already has runs nothing</p>
  <ul class="log">${() => log.value.map((line) => html`<li>${line}</li>`)}</ul>
</div>`;

const host = document.getElementById('demo');
if (host) mount(Demo, host);
live preview● running

See also