Behaviour

Side effects.

effect [ ...deps ] { … } blocks attach declarative, anonymous side effects to the program or to a single component instance. Use them for polling, hydration, analytics, keyboard listeners, autosave — anything that should run when dependencies change rather than when a user clicks a button.

Anatomy of an effect

An effect declaration has three parts:

effect [ ...dependencies ] {
  // body
}

effect { … } with no brackets is equivalent to effect [on:mount] { … } — both run the body once when the surrounding scope mounts.

Dependency entries

Each entry in the bracketed list is one of the following:

EntryMeaning
$atom Re-run the body whenever the named reactive atom changes. Mix multiple state triggers freely: [$query, $page] re-runs when either changes.
on:mount Run the body once when the surrounding component (or top-level scope) mounts.
on:unmount Run the body once when it unmounts. Useful for teardown / analytics on close.
on:every(N) Re-run the body every N milliseconds. Driven by setInterval; cleared automatically on unmount.
debounce(N) Wrap the body with a trailing-edge debounce — the body only runs N ms after the last trigger. Combine with state / interval triggers.
throttle(N) Wrap the body with a throttle — the body runs immediately, then suppresses further calls until N ms have elapsed (with a trailing call for the most recent state).

Order inside the brackets does not matter. Only one rate-limit modifier (debounce or throttle) takes effect per declaration.

Scope: top-level vs. component-local

An effect can live at the program top level or inside a component body. The dependency-list syntax is identical; only the lifecycle differs.

Top-level effectComponent-local effect
Location Declared next to other top-level bindings. Declared inside a component Name() { … } body.
Mounted Once, when the program first runs. Once per component instance, the first time it renders.
Unmounted When the program is replaced (setResponse / clear()). When the instance disappears from the render tree.
Multiplicity One copy, regardless of who uses it. One copy per instance — two App() calls = two independent effects with independent timers / cleanups.

Top-level effect

Declared next to the rest of the program. It mounts as soon as the program is parsed and lives until setResponse / clear() swaps the program.

_app_ = App()
$value = 10

effect [on:every(1000)] {
  $value = $value + 1
}

component App() {
  return Box([
    Text("Value: " + $value)
  ])
}

Component-local effect

Declare the same effect inside the component body and the runtime scopes it to that instance: the interval starts when the component mounts and is cleared as soon as it leaves the tree. Two App() calls give you two independent intervals.

_app_ = App()
$value = 10

component App() {
  effect [on:every(1000)] {
    $value = $value + 1
  }
  return Box([
    Text("Value: " + $value)
  ])
}

Conditional component — effect tears down with the instance

Component-local effects are the cleanest way to scope background work to a piece of UI that is conditionally mounted. When $showWidget flips to false the Widget() instance is removed from the tree, and the runtime tears down its on:every interval, its cleanup(…) handlers, and any state subscriptions it registered.

_app_ = Stack([
  Toggle("Show widget", value: $showWidget, onChange: () => { $showWidget = !$showWidget }),
  if $showWidget { Widget() } else { null }
])
$showWidget = true
$ticks = 0

component Widget() {
  effect [on:every(500)] {
    $ticks = $ticks + 1
  }
  return Card([CardHeader("Widget"), Text("Ticks: " + $ticks)])
}

Per-instance polling

Each rendered UserCard mounts its own poll — rendering five of them produces five independent intervals, and removing one stops only that one.

_app_ = Stack([
  UserCard("alice"),
  UserCard("bob"),
  UserCard("carol")
])

component UserCard(username) {
  $status = "loading"
  effect [on:every(5000)] {
    $status = http({ url: "/api/users/" + username + "/status", method: "GET" }).data
  }
  return Card([
    CardHeader(username),
    Text("Status: " + $status)
  ])
}

Examples

Once on mount

effect [on:mount] {
  $session = http({ url: "/api/session", method: "GET" })
}

# Empty deps list is equivalent
effect {
  console.log("App mounted")
}

React to a single atom

$selectedId = null

effect [$selectedId] {
  if $selectedId {
    $detail = http({ url: "/api/items/" + $selectedId, method: "GET" })
  }
}

React to multiple atoms with debouncing

$query = ""
$page  = 1

effect [$query, $page, debounce(250)] {
  $results = http({
    url:   "/api/search",
    query: { q: $query, page: $page }
  })
}

Polling on an interval

effect [on:every(30000)] {
  $orders.refetch()
}

# Throttled interval — fire at most once per 500ms
effect [on:every(1000), throttle(500)] {
  $now = @Now()
}

Autosave with debounce

$draft = ""

effect [$draft, debounce(500)] {
  $save = http({
    url:    "/api/draft",
    method: "PUT",
    body:   { draft: $draft }
  })
}

Sync to storage

effect [$draft, debounce(500)] {
  storage.set("draft", $draft)
}

effect [on:mount] {
  $draft = storage.get("draft") ?? ""
}

Cleanup

Long-running listeners (timers, observers, event handlers, network subscriptions) MUST be torn down so they don’t leak across re-runs or unmount. Register a teardown with cleanup(fn) from inside the body — or from inside a js{} block via ctx.cleanup(fn). Cleanups run before the next re-fire AND on unmount.

effect [on:mount] {
  js{
    const onKey = (e) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault()
        ctx.host.dispatchEvent(new CustomEvent("toggle-palette", { bubbles: true }))
      }
    }
    document.addEventListener("keydown", onKey)
    ctx.cleanup(() => document.removeEventListener("keydown", onKey))
  }
}

WebSocket subscription

effect [on:mount] {
  js{
    const socket = new WebSocket("wss://example.com/ticker")
    socket.addEventListener("message", (event) => {
      ctx.state.set("ticker", JSON.parse(event.data))
    })
    ctx.cleanup(() => socket.close())
  }
}

IntersectionObserver for infinite scroll

effect [on:mount] {
  js{
    const sentinel = ctx.host.shadowRoot?.querySelector("[data-sentinel]")
    if (!sentinel) return
    const observer = new IntersectionObserver((entries) => {
      if (entries.some((e) => e.isIntersecting)) {
        const page = ctx.state.get("page") || 1
        ctx.state.set("page", page + 1)
      }
    })
    observer.observe(sentinel)
    ctx.cleanup(() => observer.disconnect())
  }
}

When to reach for an effect

When NOT to use an effect

Effect vs. Action

effect [ ...deps ] { … }action Name(args) { … }
TriggerMount, unmount, interval, watched atoms.Explicit call from a handler or expression.
IdentityAnonymous — the runtime keys on the source location.Named — referenced by identifier from onClick / expressions.
ArgumentsNone.Positional / named params bound inside the body.
Return valueNone.Optional — observable when called as $x = name(args).
LifecycleMounted / re-fired / torn down by the runtime — at program level or per component instance.Runs once per call.
CleanupRegistered with cleanup(fn); fires on re-run + unmount.Not applicable.

Next