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

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