Scopes
Where effects live, when they dispose, and how to attach reactive code to custom elements.
Every effect, and onCleanup belongs to a scope. When the scope disposes, everything inside it tears down. Most JSX boundaries create a scope for you — knowing which ones, and where that scope is missing, is the whole contract.
The table
| Boundary | Auto-scoped | Disposed when |
|---|---|---|
Function component body — (props) => <el/> | yes | the element is removed |
Intrinsic or class element — <div/>, <MyClass/> | yes | the element is removed |
Fragment — <>...</> | yes (via the JSX transform) | the fragment's disposables run |
Reactive child slot — {() => signal()} | yes | the slot updates or its parent unmounts |
<For> render callback, per item | yes | the item's key leaves each, or the list unmounts |
Custom element connectedCallback | no — opt in with render | you call the returned unmount in disconnectedCallback |
App root (document.getElementById("app")) | no — opt in with render | you call the returned unmount |
If you write onCleanup(...) or effect(...) inside any of the first five rows, the library owns its lifetime. At the boundaries below, you own it.
render — mount with a scoped lifetime
render(target, setup) runs setup inside a detached effectScope, appends its returned node to target, and hands back a single unmount thunk. Calling unmount removes the node from the DOM, disposes its Symbol.dispose hook, and tears down every effect / onCleanup registered inside setup.
Mounting an app
Typical entry point — mount a root component into the page container and keep a reference to the unmount thunk (e.g. for hot-module replacement):
import { } from "elements-kit/render";
import { } from "./App";
const = (
.("app")!,
() => < /> as Element,
);Inside a custom element
Use the same helper in connectedCallback; stash the returned unmount on the element and call it from disconnectedCallback.
import { onCleanup } from "elements-kit/signals";
import { render } from "elements-kit/render";
class Clock extends HTMLElement {
#unmount?: () => void;
#template = () => {
const el = document.createElement("time");
const id = setInterval(() => (el.textContent = String(Date.now())), 1000);
onCleanup(() => clearInterval(id));
return el;
};
connectedCallback() {
this.#unmount = render(this, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}Effects must live inside the render callback
Put every effect and onCleanup inside the render callback. Anything created outside — in the constructor, a field initializer, or attributeChangedCallback — has no owning scope and will not stop when the element disconnects. The same rule applies to effects started from a timer or event handler: create them inside the render callback, not on first event. computed is exempt — it's lazy and disposes itself when it has no subscribers, so a class-field computed is fine.
render runs setup inside an effectScope detached from any enclosing effect — re-running an outer effect won't tear your scope down; only calling the returned unmount will.
Why For items get their own scope
Each <For> item runs its render callback inside its own effectScope. onCleanup registered during render fires when that item's key leaves each — not only on full-list unmount. This keeps per-row resources (subscriptions, intervals, observers) bound to the row's actual lifetime.
<For each={rows} by={(r) => r.id}>
{(row) => {
const off = subscribe(row.id);
onCleanup(off); // fires when this row is removed
return <li>{row.label}</li>;
}}
</For>See also
- Signals —
effect,effectScope,onCleanup. - For (list rendering) — per-item scope details.
- Custom elements overview — lifecycle hooks.