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
}
- The
effectkeyword — effects are anonymous; there is no name and noonkeyword. - An optional bracketed dependency list that controls when the body runs.
- A
{ … }body that performs the side effect — HTTP calls, state writes,js{ … }blocks,emit,cleanup(…), …
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:
| Entry | Meaning |
|---|---|
$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 effect | Component-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
- Background data refresh — interval polling, retry-on-focus, periodic stat updates.
- Lifecycle setup & teardown — one-time hydration on mount, listener teardown on unmount.
- Reactive autosave — persist a form / draft when a watched atom changes, debounced so you don’t flood the network.
- Derived async work — refetch search results when filters change, fetch detail data when a selection is made.
- Analytics & logging — track page views, log state transitions, instrument feature usage.
- Browser APIs — clipboard listeners, keyboard shortcuts,
IntersectionObserver,MutationObserver,matchMedia— usually inside ajs{}block.
When NOT to use an effect
- User-driven side effects — declare an
actionand wire it to a button instead. - HTTP that already depends on a reactive atom —
$results = http({ query: { q: $query } })already re-issues when$querychanges; no effect is needed. - Pure computations — a plain binding (
total = @Sum($cart.price)) is reactive and re-derives automatically. - Per-element listeners — component event props (
onClick,onChange,onSubmit) are simpler and don’t need cleanup.
Effect vs. Action
effect [ ...deps ] { … } | action Name(args) { … } | |
|---|---|---|
| Trigger | Mount, unmount, interval, watched atoms. | Explicit call from a handler or expression. |
| Identity | Anonymous — the runtime keys on the source location. | Named — referenced by identifier from onClick / expressions. |
| Arguments | None. | Positional / named params bound inside the body. |
| Return value | None. | Optional — observable when called as $x = name(args). |
| Lifecycle | Mounted / re-fired / torn down by the runtime — at program level or per component instance. | Runs once per call. |
| Cleanup | Registered with cleanup(fn); fires on re-run + unmount. | Not applicable. |
Next
Actions
Imperative blocks triggered by events — the user-driven counterpart to effect.
JavaScript interactions
The js{ … } escape hatch — browser APIs the declarative surface doesn’t cover.
Language reference
Reactive state, effects, control flow, built-in @-functions.