Skip to content

Scopes

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

BoundaryAuto-scopedDisposed when
Function component body — (props) => <el/>yesthe element is removed
Intrinsic or class element — <div/>, <MyClass/>yesthe element is removed
Fragment — <>...</>yes (via the JSX transform)the fragment’s disposables run
Reactive child slot — {() => signal()}yesthe slot updates or its parent unmounts
<For> render callback, per itemyesthe item’s key leaves each, or the list unmounts
Custom element connectedCallbackno — opt in with renderyou call the returned unmount in disconnectedCallback
App root (document.getElementById("app"))no — opt in with renderyou 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 {
function render(target: Element | DocumentFragment, setup: () => Node | null | undefined): () => void

Mount a node into target with a scoped lifetime.

setup runs inside a detached effectScope. The returned node is appended to target. Calling the returned unmount removes the node from the DOM, disposes its Symbol.dispose hook (JSX-created elements carry one), and tears down every effect / onCleanup registered inside setup.

@example

import { render } from "elements-kit/render";
const unmount = render(document.getElementById("app")!, () => <App />);
// later
unmount();

render
} from "elements-kit/render";
import {
import App
App
} from "./App";
const
const unmount: () => void
unmount
=
function render(target: Element | DocumentFragment, setup: () => Node | null | undefined): () => void

Mount a node into target with a scoped lifetime.

setup runs inside a detached effectScope. The returned node is appended to target. Calling the returned unmount removes the node from the DOM, disposes its Symbol.dispose hook (JSX-created elements carry one), and tears down every effect / onCleanup registered inside setup.

@example

import { render } from "elements-kit/render";
const unmount = render(document.getElementById("app")!, () => <App />);
// later
unmount();

render
(
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
Document.getElementById(elementId: string): HTMLElement | null

The getElementById() method of the Document interface returns an Element object representing the element whose id property matches the specified string. Since element IDs are required to be unique if specified, they're a useful way to get access to a specific element quickly.

getElementById
("app")!,
() => <
import App
App
/> as
interface Element

Element is the most general base class from which all element objects (i.e., objects that represent elements) in a Document inherit. It only has methods and properties common to all kinds of elements. More specific classes inherit from Element.

MDN Reference

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;
}
}

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