Using Web Components with dd<el>: Better Together
dd<el> pairs powerfully with Web Components to create reusable, encapsulated custom elements with all the benefits of dd<el>’s declarative DOM construction and reactivity system.
Why Combine dd<el> with Web Components?
- Declarative DOM creation within your components
- Reactive attribute updates through signals
- Simplified event handling with the same events API
- Clean component lifecycle management
// use NPM or for example https://cdn.jsdelivr.net/gh/jaandrle/deka-dom-el/dist/esm-with-signals.js
import {
customElementRender,
customElementWithDDE,
} from "deka-dom-el";
/** @type {ddePublicElementTagNameMap} */
import { S } from "deka-dom-el/signals";
S.observedAttributes;
// “internal” utils
import { lifecyclesToEvents } from "deka-dom-el";
# Getting Started: Web Components Basics
Web Components are a set of standard browser APIs that let you create custom HTML elements with encapsulated functionality. They consist of three main technologies:
- Custom Elements: Create your own HTML tags with JS-defined behavior
- Shadow DOM: Encapsulate styles and markup within a component
- HTML Templates: Define reusable markup structures (the dd<el> replaces this part)
Let’s start with a basic Custom Element example without dd<el> to establish the foundation:
export class HTMLCustomElement extends HTMLElement{
static tagName= "custom-element"; // just suggestion, we can use `el(HTMLCustomElement.tagName)`
static observedAttributes= [ "custom-attribute" ];
constructor(){
super();
// nice place to prepare custom element
}
connectedCallback(){
// nice place to render custom element
}
attributeChangedCallback(name, oldValue, newValue){
// listen to attribute changes (see `S.observedAttributes`)
}
disconnectedCallback(){
// nice place to clean up
}
// for example, we can mirror get/set prop to attribute
get customAttribute(){ return this.getAttribute("custom-attribute"); }
set customAttribute(value){ this.setAttribute("custom-attribute", value); }
}
customElements.define(HTMLCustomElement.tagName, HTMLCustomElement);
For complete information on Web Components, see the MDN documentation. Also, Handy Custom Elements Patterns provides useful techniques for connecting attributes with properties.
# dd<el> Integration: Step 1 - Event Handling
The first step in integrating dd<el> with Web Components is enabling dd<el>’s event system to work with your Custom Elements. This is done with customElementWithDDE
, which makes your Custom Element compatible with dd<el>’s event handling. (Notice that customElementWithDDE is actually decorator)
customElementWithDDE
- Purpose
- Enables dd<el>’s event system to work with your Custom Element
- Usage
- customElementWithDDE(YourElementClass)
- Benefits
- Allows using on.connected(), on.disconnected() or S.observedAttributes().
import { customElementWithDDE, el, on } from "./esm-with-signals.js";
export class HTMLCustomElement extends HTMLElement{
static tagName= "custom-element";
connectedCallback(){
this.append(
el("p", "Hello from custom element!")
);
}
}
customElementWithDDE(HTMLCustomElement);
customElements.define(HTMLCustomElement.tagName, HTMLCustomElement);
const instance= el(HTMLCustomElement.tagName);
on.connected( // preffered
e=> console.log("Element connected to the DOM (v1):", e)
)(instance);
instance.addEventListener(
"dde:connected",
e=> console.log("Element connected to the DOM (v2):", e)
);
document.body.append(
instance,
);
Key Point: The customElementWithDDE
function adds event dispatching to your Custom Element lifecycle methods, making them work seamlessly with dd<el>’s event system.
# dd<el> Integration: Step 2 - Rendering Components
The next step is to use dd<el>’s component rendering within your Custom Element. This is done with customElementRender
, which connects your dd<el> component function to the Custom Element.
customElementRender
- Purpose
- Connects a dd<el> component function to a Custom Element
- Parameters
- Target (usually this or this.shadowRoot)
- Component function that returns a DOM tree
- Optional: Attributes transformer function (empty by default or S.observedAttributes)
- Returns
- The rendered DOM tree
import {
customElementRender,
customElementWithDDE,
} from "./esm-with-signals.js";
export class HTMLCustomElement extends HTMLElement{
static tagName= "custom-element";
static observedAttributes= [ "attr" ];
connectedCallback(){
customElementRender(
this.attachShadow({ mode: "open" }),
ddeComponent,
this
);
}
set attr(value){ this.setAttribute("attr", value); }
get attr(){ return this.getAttribute("attr"); }
}
import { el, on, scope } from "./esm-with-signals.js";
function ddeComponent({ attr }){
scope.host(
on.connected(e=> console.log(e.target.outerHTML)),
);
return el().append(
el("p", `Hello from Custom Element with attribute '${attr}'`)
);
}
customElementWithDDE(HTMLCustomElement);
customElements.define(HTMLCustomElement.tagName, HTMLCustomElement);
document.body.append(
el(HTMLCustomElement.tagName, { attr: "Attribute" })
);
In this example, we’re using Shadow DOM (this.attachShadow()
) for encapsulation, but you can also render directly to the element with customElementRender(this, ...)
.
# Reactive Web Components with Signals
One of the most powerful features of integrating dd<el> with Web Components is connecting HTML attributes to dd<el>’s reactive signals system. This creates truly reactive custom elements.
Two Ways to Handle Attributes:
- Using standard attribute access (
this.getAttribute(<name>)
) - Passes attributes as regular values (static) S.observedAttributes
- Transforms attributes into signals (reactive)
Using the S.observedAttributes
creates a reactive connection between your element’s attributes and its internal rendering. When attributes change, your component automatically updates!
import {
customElementRender,
customElementWithDDE,
el, on, scope,
} from "./esm-with-signals.js";
import { S } from "./esm-with-signals.js";
export class HTMLCustomElement extends HTMLElement{
static tagName= "custom-element";
static observedAttributes= [ "attr" ];
connectedCallback(){
customElementRender(
this.attachShadow({ mode: "open" }),
ddeComponent,
S.observedAttributes
);
}
set attr(value){ this.setAttribute("attr", value); }
get attr(){ return this.getAttribute("attr"); }
}
/** @param {{ attr: ddeSignal<string, {}> }} props */
function ddeComponent({ attr }){
scope.host(
on.connected(e=> console.log(( /** @type {HTMLParagraphElement} */ (e.target)).outerHTML)),
);
return el().append(
el("p", S(()=> `Hello from Custom Element with attribute '${attr.get()}'`))
);
}
customElementWithDDE(HTMLCustomElement);
customElements.define(HTMLCustomElement.tagName, HTMLCustomElement);
document.body.append(
el(HTMLCustomElement.tagName, { attr: "Attribute" })
);
setTimeout(
()=> document.querySelector(HTMLCustomElement.tagName).setAttribute("attr", "New Value"),
3*750
);
How S.observedAttributes Works
- Takes each attribute listed in static observedAttributes
- Creates a dd<el> signal for each one
- Automatically updates these signals when attributes change
- Passes the signals to your component function
- In opposite, updates of signals trigger attribute changes
- Your component reacts to changes through signal subscriptions
# Working with Shadow DOM
Shadow DOM provides encapsulation for your component’s styles and markup. When using dd<el> with Shadow DOM, you get the best of both worlds: encapsulation plus declarative DOM creation.
Shadow DOM Encapsulation
<my-custom-element>
┌─────────────────────────┐
#shadow-root
Created with dd<el>
┌──────────────────┐
<div>
<h2>Title</h2>
<p>Content</p>
import {
el,
customElementRender,
customElementWithDDE,
} from "./esm-with-signals.js";
function ddeComponent(){
return el().append(
el("style", `
.red{ color: firebrick; }
`),
el("p", { className: "red" }).append(
"Hello from ", el("slot", "Custom Element"), "!"
)
);
}
export class A extends HTMLElement{
static tagName= "custom-element-without";
connectedCallback(){
customElementRender(this, ddeComponent);
}
}
customElementWithDDE(A);
customElements.define(A.tagName, A);
export class B extends HTMLElement{
static tagName= "custom-element-open";
connectedCallback(){
customElementRender(
this.attachShadow({ mode: "open" }),
ddeComponent
);
}
}
customElementWithDDE(B);
customElements.define(B.tagName, B);
export class C extends HTMLElement{
static tagName= "custom-element-closed";
connectedCallback(){
customElementRender(
this.attachShadow({ mode: "closed" }),
ddeComponent
);
}
}
customElementWithDDE(C);
customElements.define(C.tagName, C);
document.body.append(
el(A.tagName).append("Without shadowRoot"),
el("hr"),
el(B.tagName).append("Open shadowRoot"),
el("hr"),
el(C.tagName).append("Closed shadowRoot"),
el("style", `
.red{ color: crimson; }
`),
);
console.log(A.tagName, "expect modifications");
document.body.querySelector(A.tagName).querySelector("p").textContent+= " (editable with JS)";
console.log(B.tagName, "expect modifications");
document.body.querySelector(B.tagName).shadowRoot.querySelector("p").textContent+= " (editable with JS)";
console.log(C.tagName, "expect error ↓");
document.body.querySelector(C.tagName).querySelector("p").textContent+= " (editable with JS)";
For more information on Shadow DOM, see Using Shadow DOM, or the comprehensive Shadow DOM in Depth.
# Working with Slots
Besides the encapsulation, the Shadow DOM allows for using the <slot>element(s). You can simulate this feature using simulateSlots
:
import {
customElementRender,
customElementWithDDE,
el,
simulateSlots
} from "./esm-with-signals.js";
export class HTMLCustomElement extends HTMLElement{
static tagName= "custom-slotting";
connectedCallback(){
const c= ()=> simulateSlots(this, ddeComponent());
customElementRender(this, c);
}
}
customElementWithDDE(HTMLCustomElement);
customElements.define(HTMLCustomElement.tagName, HTMLCustomElement);
document.body.append(
el(HTMLCustomElement.tagName),
el(HTMLCustomElement.tagName).append(
"Slot"
),
el(ddeComponentSlot),
el(ddeComponentSlot).append(
"Slot"
),
);
function ddeComponent(){
return el().append(
el("p").append(
"Hello ", el("slot", "World")
)
);
}
function ddeComponentSlot(){
return simulateSlots(el().append(
el("p").append(
"Hello ", el("slot", "World")
)
));
}
simulateSlots
- Purpose
- Provides slot functionality when you cannot/do not want to use shadow DOM
- Parameters
- A mapping object of slot names to DOM elements
# Best Practices for Web Components with dd<el>
When combining dd<el> with Web Components, follow these recommendations:
- Always use customElementWithDDE to enable event integration
- Prefer S.observedAttributes for reactive attribute connections
- Create reusable component functions that your custom elements render
- Use scope.host() to clean up event listeners and subscriptions
- Add setters and getters for better property access to your element
Common Issues
- Events not firing properly
- Make sure you called customElementWithDDE before defining the element
- Attributes not updating
- Check that you’ve properly listed them in static observedAttributes
- Component not rendering
- Verify customElementRender is called in connectedCallback, not constructor
# Mnemonic
customElementRender(<connect-target>, <render-function>[, <properties>])
— use function to render DOM structure for given custom element (or its Shadow DOM)customElementWithDDE(<custom-element>)
— register <custom-element> to DDE library, see also `lifecyclesToEvents`, can be also used as decoratorS.observedAttributes(<custom-element>)
— returns record of observed attributes (keys uses camelCase and values are signals)lifecyclesToEvents(<class-declaration>)
— convert lifecycle methods to events, can be also used as decoratorsimulateSlots(<class-instance>, <body>)
— simulate slots for Custom Elements without shadow DOMsimulateSlots(<dde-component>[, <mapper>])
— simulate slots for “dde”/functional components