Declarative Event Handling and Addons
Events are at the core of interactive web applications. dd<el> provides a clean, declarative approach to handling DOM events and extends this pattern with a powerful Addon system to incorporate additional functionalities into your UI templates.
Why dd<el>’s Event System and Addons Matters
- Integrate event handling directly in element declarations
- Leverage lifecycle events for better component design
- Clean up listeners automatically with abort signals
- Extend elements with custom behaviors using Addons
- Maintain clean, readable code with consistent patterns
// use NPM or for example https://cdn.jsdelivr.net/gh/jaandrle/deka-dom-el/dist/esm.js
import { on, dispatchEvent } from "deka-dom-el";
/** @type {ddeElementAddon} */
# Events and Listeners: Two Approaches
In JavaScript you can listen to native DOM events using element.addEventListener(type, listener, options)
. dd<el> provides an alternative approach with arguments ordered differently to better fit its declarative style:
Native DOM API
element.addEventListener("click", callback, options);
dd<el> Approach
on("click", callback, options)(element);
The main benefit of dd<el>’s approach is that it works as an Addon (see below), making it easy to integrate directly into element declarations.
import { el, on } from "./esm-with-signals.js";
const log= mark=> console.log.bind(console, mark);
const button= el("button", "Test click");
button.addEventListener("click", log("`addEventListener`"), { once: true });
on("click", log("`on`"), { once: true })(button);
document.body.append(
button
);
# Removing Event Listeners
Unlike the native addEventListener/removeEventListener pattern, dd<el> uses only AbortSignal for declarative removal:
import { el, on } from "./esm-with-signals.js";
const log= mark=> console.log.bind(console, mark);
const abort_controller= new AbortController();
const { signal }= abort_controller;
const button= el("button", "Test click");
button.addEventListener("click", log("`addEventListener`"), { signal });
on("click", log("`on`"), { signal })(button);
document.body.append(
button, " ", el("button", { textContent: "Off", onclick: ()=> abort_controller.abort() })
);
This is the same for signals (see next section) and works well with scopes and library extendability ( see scopes and extensions section — mainly scope.signal
).
# Three Ways to Handle Events
HTML Attribute Style
import { el } from "deka-dom-el";
// Using events with HTML attribute style
el("button", {
textContent: "click me",
"=onclick": "console.log(event)"
});
Forces usage as an HTML attribute. Corresponds to <button onclick="console.log(event)">click me</button>
. This can be particularly useful for SSR scenarios.
Property Assignment
import { el } from "deka-dom-el";
// Using events with property assignment
el("button", {
textContent: "click me",
onclick: console.log
});
Assigns the event handler directly to the element’s property.
Addon Approach
import { el, on } from "deka-dom-el";
// Using events as addons - chainable approach
el("button", {
textContent: "click me",
},
on("click", (e) => console.log("Clicked!", e))
);
Uses the addon pattern (so adds the event listener to the element), see above.
For a deeper comparison of these approaches, see WebReflection’s detailed analysis.
# Understanding Addons
Addons are a powerful pattern in dd<el> that extends beyond just event handling. An Addon is any function that accepts an HTML element as its first parameter.
What Can Addons Do?
- Add event listeners to elements
- Set up lifecycle behaviors
- Integrate third-party libraries
- Create reusable element behaviors
- Capture element references
You can use Addons as ≥3rd argument of the el
function, making it possible to extend your templates with additional functionality:
import { el, on } from "./esm-with-signals.js";
const abort_controller= new AbortController();
const { signal }= abort_controller;
/** @type {ddeElementAddon<HTMLButtonElement>} */
const onclickOff= on("click", ()=> abort_controller.abort(), { signal });
/** @type {(ref?: HTMLOutputElement)=> HTMLOutputElement | null} */
const ref= (store=> ref=> ref ? (store= ref) : store)(null);
document.body.append(
el("button", "Test `on`",
on("click", console.log, { signal }),
on("click", update, { signal }),
on("mouseup", update, { signal })),
" ",
el("button", "Off", onclickOff),
el("output", { style: { display: "block", whiteSpace: "pre" } }, ref)
);
/** @param {MouseEvent} event @this {HTMLButtonElement} */
function update(event){
ref().append(
event.type,
"\n"
);
}
As the example shows, you can provide types in JSDoc+TypeScript using the global type ddeElementAddon
. Notice how Addons can also be used to get element references.
# Lifecycle Events
Addons are called immediately when an element is created, even before it’s connected to the live DOM. You can think of an Addon as an “oncreate” event handler.
dd<el> provides two additional lifecycle events that correspond to custom element lifecycle callbacks and component patterns:
- on.connected(callback)
- Fires when the element is added to the DOM
- on.disconnected(callback)
- Fires when the element is removed from the DOM
import { el, on } from "./esm-with-signals.js";
function allLifecycleEvents(){
return el("form", null,
el=> log({ type: "dde:created", detail: el }),
on.connected(log),
on.disconnected(log),
).append(
el("select", { id: "country" }, on.defer(select => {
// This runs when the select is ready with all its options
select.value = "cz"; // Pre-select Czechia
log({ type: "dde:on.defer", detail: select });
})).append(
el("option", { value: "au", textContent: "Australia" }),
el("option", { value: "ca", textContent: "Canada" }),
el("option", { value: "cz", textContent: "Czechia" }),
),
el("p", "See lifecycle events in console."),
);
}
document.body.append(
el(allLifecycleEvents),
el("button", "Remove Element", on("click", function(){
this.previousSibling.remove();
}))
);
/** @param {Partial<CustomEvent>} event */
function log({ type, detail }){
console.log({ _this: this, type, detail });
}
For regular elements (non-custom elements), dd<el> uses MutationObserver
| MDN internally to track lifecycle events.
- Always use
on.*
functions as library must ensure proper (MutationObserver) registration, noton('dde:*', ...)
, even the native event system is used with event names prefixed withdde:
. - Use lifecycle events sparingly, as they require internal tracking
- Leverage parent-child relationships: when a parent is removed, all children are also removed
- …see section later in documentation regarding hosts elements
- dd<el> ensures that connected/disconnected events fire only once for better predictability
# Utility Helpers
You can use the on.defer
helper to defer execution to the next event loop. This is useful for example when you wan to set some element properties based on the current element body (typically the <select value="...">
).
- on.defer(callback)
- Helper that defers function execution to the next event loop (using setTimeout)
# Dispatching Custom Events
This makes it easy to implement component communication through events, following standard web platform patterns. The curried approach allows for easy reuse of event dispatchers throughout your application.
import { el, on, dispatchEvent } from "./esm-with-signals.js";
document.body.append(
el("p", "Listenning to `test` event.", on("test", console.log)).append(
el("br"),
el("button", "native", on("click", native)),
" ",
el("button", "dde", on("click", dde)),
" ",
el("button", "dde with options", on("click", ddeOptions))
)
);
function native(){
this.dispatchEvent(
new CustomEvent("test",
{ bubbles: true, detail: "hi" }
)
);
}
function dde(){
dispatchEvent("test")(this.parentElement, "hi");
}
function ddeOptions(){
dispatchEvent("test", { bubbles: true })(this, "hi");
}
import { el, on, dispatchEvent, scope } from "deka-dom-el";
document.body.append(
el(component),
);
function component(){
const { host }= scope;
const dispatchExample= dispatchEvent(
"example",
{ bubbles: true },
host
);
return el("div").append(
el("p", "Dispatch events from outside of the component."),
el("button", { textContent: "Dispatch", type: "button" },
on("click", dispatchExample))
);
}
# Best Practices
- Clean up listeners: Use AbortSignal to prevent memory leaks
- Leverage lifecycle events: For component setup and teardown
- Delegate when possible: Add listeners to container elements when handling many similar elements
- Maintain consistency: Choose one event binding approach and stick with it
Common Event Pitfalls
- Event listeners not working
- Ensure element is in the DOM before expecting events to fire
- Memory leaks
- Use AbortController to clean up listeners when elements are removed
- Lifecycle events firing unexpectedly
- Remember that on.connected and on.disconnected events only fire once per connection state
# Mnemonic
on(<event>, <listener>[, <options>])(<element>)
— just<element>.addEventListener(<event>, <listener>[, <options>])
on.<live-cycle>(<event>, <listener>[, <options>])(<element>)
— corresponds to custom elemnets callbacks<live-cycle>Callback(...){...}
. To connect to custom element see following page, else it is simulated by MutationObserver.on.defer(<identity>=> <identity>)(<identity>)
— calls callback laterdispatchEvent(<event>[, <options>])(element)
— just<element>.dispatchEvent(new Event(<event>[, <options>]))
dispatchEvent(<event>[, <options>])(<element>[, <detail>])
— just<element>.dispatchEvent(new Event(<event>[, <options>] ))
or<element>.dispatchEvent(new CustomEvent(<event>, { detail: <detail> }))
dispatchEvent(<event>[, <options>], <host>)([<detail>])
— just<host>().dispatchEvent(new Event(<event>[, <options>]))
or<host>().dispatchEvent(new CustomEvent(<event>, { detail: <detail> }[, <options>] ))
(see scopes section of docs)