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
- Capture host early: Use
const { host } = scope
at component start - Define signals as constants:
const counter = S(0);
- Prefer declarative patterns: Use signals to drive UI updates rather than manual DOM manipulation
- Keep components focused: Each component should do one thing well
- 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 functionscope.host()
— get current component referencescope.host(...<addons>)
— use addons to current componentscope.signal
— get AbortSignal that triggers when the element disconnects