Building Maintainable UIs with Scopes and Components

For state-less components we can use functions as UI components (see “Elements” page). But in real life, we may need to handle the component’s life-cycle and provide JavaScript the way to properly use the Garbage collection.

// use NPM or for example https://cdn.jsdelivr.net/gh/jaandrle/deka-dom-el/dist/esm-with-signals.js import { scope, el } from "deka-dom-el"; /** @type {ddeElementAddon} */

The library therefore uses scopes to provide these functionalities.

# Understanding Host Elements and Scopes

The host is the name for the element representing the component. This is typically the element returned by a function. To get a reference, you can use scope.host(). To apply addons, just use scope.host(...<addons>).

Scopes are primarily needed when signals are used in DOM templates (with el, assign, or S.el). They provide a way for automatically removing signal listeners and cleaning up unused signals when components are removed from the DOM.

Component Anatomy

// 1. Component scope created
el(MyComponent);

function MyComponent() {
	// 2. access the host element (or other scope related values)
	const { host } = scope;

	// 3. Add behavior to host
	host(
		on.click(handleClick)
	);

	// 4. Return the host element
	return el("div", {
		className: "my-component"
	}).append(
		el("h2", "Title"),
		el("p", "Content"),
	);
}

scope.host()

When called with no arguments
Returns a reference to the host element (the root element of your component)
When called with addons/callbacks
Applies the addons to the host element (and returns the host element)
import { el, on, scope } from "./esm-with-signals.js"; const { host }= scope; host( element=> console.log( "This represents Addon/oninit for root", element.outerHTML ) ); console.log( "This represents the reference to the host element of root", host().outerHTML ); document.body.append( el(component) ); function component(){ const { host }= scope; host( element=> console.log( "This represents Addon/oninit for the component", element.outerHTML ) ); const onclick= on("click", function(ev){ console.log( "This represents the reference to the host element of the component", host().outerHTML ); }) return el("div", null, onclick).append( el("strong", "Component") ); }

Best Practice: Always capture the host reference (or other scope related values) at the beginning of your component function using const { host } = scope to avoid scope-related issues, especially with asynchronous code.

If you are interested in the implementation details, see Class-Based Components section.

import { el, scope, on, dispatchEvent } from "deka-dom-el"; document.body.append( el(component) ); function component(){ const { host }= scope; // good practise! host( console.log, on("click", function redispatch(){ // this `host` ↘ still corresponds to the host ↖ of the component dispatchEvent("redispatch")(host()); }) ); // this `host` ↘ still corresponds to the host ↖ of the component setTimeout(()=> dispatchEvent("timeout")(host()), 750) return el("p", "Clickable paragraph!"); }

# Class-Based Components

While functional components are the primary pattern in dd<el>, you can also create class-based components. For this, we implement function elClass and use it to demonstrate implementation details for better understanding of the scope logic.

import { el } from "./esm-with-signals.js"; class Test { constructor(params){ this._params= params; } render(){ return el("div").append( this._params.textContent ); } } document.body.append( elClass(Test, { textContent: "Hello World" }) ); import { chainableAppend, scope } from "./esm-with-signals.js"; function elClass(_class, attributes, ...addons){ let element, element_host; scope.push({ scope: _class, //just informative purposes host: (...addons_append)=> addons_append.length ? ( !element ? addons.unshift(...addons_append) : addons_append.forEach(c=> c(element_host)) , undefined) : element_host }); const instance= new _class(attributes); element= instance.render(); const is_fragment= element instanceof DocumentFragment; const el_mark= el.mark({ //this creates html comment `<dde:mark …/>` type: "class-component", name: _class.name, host: is_fragment ? "this" : "parentElement", }); element.prepend(el_mark); if(is_fragment) element_host= el_mark; chainableAppend(element); addons.forEach(c=> c(element_host)); scope.pop(); return element; }

# Automatic Cleanup with Scopes

One of the most powerful features of scopes is automatic cleanup when components are removed from the DOM. This prevents memory leaks and ensures resources are properly released.

Lifecycle Flow

1. Component created → scope established
2. Component added to DOM → connected event
3. Component interactions happen
4. Component removed from DOM → disconnected event
5. Automatic cleanup of:
	- Event listeners (browser)
	- Signal subscriptions (dd<el> and browser)
	- Custom cleanup code (dd<el> and user)
import { el, on } from "./esm-with-signals.js"; /** @param {HTMLElement} el */ const empty= el=> Array.from(el.children).forEach(c=> c.remove()); document.body.append( el(component), el("button", { textContent: "Remove", onclick: ()=> empty(document.body), type: "button" }) ); import { S } from "./esm-with-signals.js"; function component(){ const textContent= S("Click to change text."); const onclickChange= on("click", function redispatch(){ textContent.set("Text changed! "+(new Date()).toString()) }); return el("p", textContent, onclickChange); }

In this example, when you click "Remove", the component is removed from the DOM, and all its associated resources are automatically cleaned up, including the signal subscription that updates the text content. This happens because the library internally registers a disconnected event handler on the host element.

# Declarative vs Imperative Components

The library DOM API and signals work best when used declaratively. It means you split your app’s logic into three parts as introduced in Signals (3PS).

Strictly speaking, the imperative way of using the library is not prohibited. Just be careful to avoid mixing the declarative approach (using signals) with imperative manipulation of elements.

✅ Declarative Approach

Define what your UI should look like based on state:

import { el } from "deka-dom-el"; import { S } from "deka-dom-el/signals"; function Counter() { // Define state const count = S(0); // Define behavior const increment = () => count.set(count.get() + 1); // Define data flow setTimeout(increment, 1000); // or fetchAPI().then(increment); // Declarative UI (how to render data/`count`) // …automatically updates when changes return el("div").append( // declarative element(s) el("p", S(() => "Count: " + count.get())), el("button", { onclick: increment, textContent: "Increment", // declarative attribute(s) disabled: S(() => count.get() >= 10) }) ); }

⚠️ Imperative Approach

Manually update the DOM in response to events:

/* PSEUDO-CODE!!! */ import { el, scope } from "deka-dom-el"; function Counter() { const { host } = scope; let count = 0; const counterText = el("p", "Count: 0"); // Manually update DOM element const increment = () => { count++; counterText.textContent = "Count: " + count; host().querySelector("button").disabled = count >= 10; }; setTimeout(increment, 1000); // or fetchAPI().then(increment); return el("div").append( counterText, el("button", { onclick: increment, textContent: "Increment" }) ); }

❌ Mixed Approach

This approach should be avoided:

/* PSEUDO-CODE!!! */ import { el, scope } from "deka-dom-el"; import { S } from "deka-dom-el/signals"; function Counter() { const { host } = scope; let count = S(0); const counterText = el("p", "Count: 0"); S.on(count, c=> counterText.textContent= "Count: " + c); // Manually update DOM element const increment = () => { count.set(count.get() + 1); // NEVER EVER // count = S(count.get() + 1); // THE HOST IS PROBABLY DIFFERENT THAN // YOU EXPECT AND SIGNAL MAY BE // UNEXPECTEDLY REMOVED!!! S.on(count, (count)=> host().querySelector("button").disabled = count >= 10 ); }; setTimeout(()=> { // ok, BUT consider extract to separate function // see section below for more info const ok= S(0); S.on(ok, console.log); setInterval(()=> ok.set(ok.get() + 1), 100); }, 100); return el("div").append( counterText, el("button", { onclick: increment, textContent: "Increment" }) ); }

# Best Practices for Scopes and Components

  1. Capture host early: Use const { host } = scope at component start
  2. Define signals as constants: const counter = S(0);
  3. Prefer declarative patterns: Use signals to drive UI updates rather than manual DOM manipulation
  4. Keep components focused: Each component should do one thing well
  5. Add explicit cleanup: For resources not managed by dd<el>, use on.disconnected

Common Scope Pitfalls

Losing host reference in async code
Store host reference early with const { host } = scope
Memory leaks from custom resources
Use host(on.disconnected(cleanup)) for manual resource cleanup
Event handlers with incorrect 'this'
Use arrow functions or .bind() to preserve context
Mixing declarative and imperative styles
Choose one approach and be consistent throughout a component(s)

# Mnemonic

  • el(<function>, <function-argument(s)>)[.append(...)]: <element-returned-by-function> — using component represented by function
  • scope.host() — get current component reference
  • scope.host(...<addons>) — use addons to current component
  • scope.signal — get AbortSignal that triggers when the element disconnects