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
effect(fn: () => unknown): () => void
Parameters
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
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.
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.
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
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);