effect()

Run a side-effect function that automatically re-runs when its signal dependencies change.

const dispose = effect(fn, opts?)

Parameters

ParameterTypeDescription
fn () => void | (() => void) The effect function. All signals read inside are tracked. May optionally return a cleanup function.
opts { stable?: boolean } Optional. When stable: true, the effect skips re-tracking dependencies on re-run (assumes deps never change after first run). It also runs synchronously and inline on each write outside a batch(), instead of being queued for the microtask flush. Only use it when the dependency set is provably fixed.

Returns

A dispose function. Calling it stops the effect and runs any cleanup.

ReturnTypeDescription
dispose () => void Disposes the effect, unsubscribes from all tracked signals, and runs the cleanup function (if one was returned).

Usage

Basic Usage

import { signal, effect } from 'what-framework';

const count = signal(0);

// Runs immediately, then re-runs when count changes
effect(() => {
  console.log('Count:', count());
});

count.set(5);
// Nothing logged yet: the re-run is queued.
await Promise.resolve();
// Now logged: "Count: 5"

With Cleanup

const url = signal('/api/data');

effect(() => {
  const controller = new AbortController();

  fetch(url(), { signal: controller.signal })
    .then(r => r.json())
    .then(console.log);

  // Cleanup: abort the request when effect re-runs or disposes
  return () => controller.abort();
});

Disposing an Effect

A write only queues the re-run. Flush it with flushSync() when you need the effect applied before the next line, because dispose() on the line after a write cancels the queued run and it never happens at all.

import { signal, effect, flushSync } from 'what-framework';

const count = signal(0);

const dispose = effect(() => {
  console.log(count());
});

count.set(1);
flushSync();  // Logs: 1

dispose();

count.set(2);  // Nothing happens, the effect is disposed

Event Listener Pattern

An effect() in a component body is not tied to that component, so capture its dispose function and hand it to onCleanup(). Without that the listener survives unmount.

import { effect, onCleanup } from 'what-framework';

function ResizeWatcher() {
  const stop = effect(() => {
    const handler = () => console.log('resize');
    window.addEventListener('resize', handler);

    return () => {
      window.removeEventListener('resize', handler);
    };
  });

  onCleanup(stop);

  return <div />;
}

Document Title Sync

const title = signal('My App');

effect(() => {
  document.title = title();
});

Scheduling

Effects are scheduled asynchronously via microtask. When a signal changes, its dependent effects are queued and flushed together in a microtask. This means updates are batched naturally when multiple signals change in the same synchronous block.

const a = signal(1);
const b = signal(2);

effect(() => {
  console.log(a() + b());
});

// Both writes happen in one synchronous block, so the effect is
// queued once and runs once on the next microtask.
a.set(10);
b.set(20);
// Output: 3 at creation, then 30 on the next microtask. Never 12.

The single-dependency fast path

An effect that tracked exactly one signal, and returned no cleanup, is promoted to a fast path after its first re-run. From then on it runs synchronously and inline at write time instead of going through the queue. So the most common effect shape behaves asynchronously on the first change and synchronously on every change after that.

const s = signal(0);
const logs = [];

effect(() => { logs.push(s()); });   // logs: [0]

s.set(1);
// still [0] here, the re-run is queued
await Promise.resolve();          // logs: [0, 1]

s.set(2);
// already [0, 1, 2] here, no await needed

Do not write code that depends on either timing. Use flushSync() when you need effects applied before the next line, and batch() when you need a consistent view across multiple writes.

Notes

  • Effects run immediately on creation (synchronously), then re-run asynchronously via microtask when dependencies change. The single-dependency fast path above is the exception.
  • Only signals actually read during execution are tracked. Conditional branches that are not taken do not create subscriptions.
  • If an effect reads and writes the same signal, it can cause infinite loops. What detects this and warns after 25 flush iterations, then drops the pending queue so the cycle stops instead of hanging the tab. Use untrack() to read without subscribing.
  • An effect() created in a component body is not disposed when the component unmounts. It keeps re-running on every later write. Capture its dispose function and pass it to onCleanup(), or use useEffect(), which registers with the component context.
  • For derived values, prefer computed() over setting a signal inside an effect.
  • The cleanup function runs before each re-execution and on final disposal.