Building Reactive UIs with Signals
Signals provide a simple yet powerful way to create reactive applications with dd<el>. They handle the fundamental challenge of keeping your UI in sync with changing data in a declarative, efficient way.
What Makes Signals Special?
- Fine-grained reactivity without complex state management
- Automatic UI updates when data changes
- Clean separation between data, logic, and UI
- Small runtime with minimal overhead
- In future no dependencies or framework lock-in
// use NPM or for example https://cdn.jsdelivr.net/gh/jaandrle/deka-dom-el/dist/esm-with-signals.js
import { S, signal } from "deka-dom-el/signals";
S===signal
/** @type {ddeSignal} */
/** @type {ddeAction} */
/** @type {ddeActions} */
# The 3-Part Structure of Signals
Signals organize your code into three distinct parts, following the 3PS principle:
PART 1: Create Signal
const count = S(0);
Define a reactive value that can be observed and changed
PART 2: React to Changes
S.on(count, value => updateUI(value));
Subscribe to signal changes with callbacks or effects
PART 3: Update Signal
count.set(count.get() + 1);
Modify the signal value, which automatically triggers updates
import { S } from "./esm-with-signals.js";
// PART 1 — `signal` represents a reactive value
const signal= S(0);
// PART 2 — just reacts on signal changes
S.on(signal, console.log);
// PART 3 — just updates the value
const update= ()=> signal.set(signal.get()+1);
update();
const interval= 5*1000;
setTimeout(clearInterval, 10*interval,
setInterval(update, interval));
Signals implement the Publish–subscribe pattern, a form of Event-driven programming. This architecture allows different parts of your application to stay synchronized through a shared signal, without direct dependencies on each other. Compare for example with fpubsub library.
# Signal Essentials: Core API
- Creating a Signal
- S(initialValue) → creates a signal with the given value
- Reading a Signal
- signal.get() → returns the current value
- Writing to a Signal
- signal.set(newValue) → updates the value and notifies subscribers
- Subscribing to Changes
- S.on(signal, callback) → runs callback whenever signal changes
- Unsubscribing
- S.on(signal, callback, { signal: abortController.signal }) → Similarly to the
on
function to register DOM events listener.
Signals can be created with any type of value, but they work best with primitive types like strings, numbers, and booleans. For complex data types like objects and arrays, you’ll want to use Actions (covered below).
# Derived Signals: Computed Values
Computed values (also called derived signals) automatically update when their dependencies change. Create them by passing a function to S()
:
import { S } from "./esm-with-signals.js";
// Create base signals
const firstName = S("John");
const lastName = S("Doe");
// Create a derived signal
const fullName = S(() => firstName.get() + " " + lastName.get());
// The fullName signal updates automatically when either dependency changes
S.on(fullName, name => console.log("Name changed to:", name));
firstName.set("Jane"); // logs: "Name changed to: Jane Doe"
Derived signals are read-only - you can’t call .set()
on them. Their value is always computed from their dependencies. They’re perfect for transforming or combining data from other signals.
import { S } from "./esm-with-signals.js";
const signal= S(0);
// computation pattern
const double= S(()=> 2*signal.get());
const ac= new AbortController();
S.on(signal, v=> console.log("signal", v), { signal: ac.signal });
S.on(double, v=> console.log("double", v), { signal: ac.signal });
signal.set(signal.get()+1);
const interval= 5 * 1000;
const id= setInterval(()=> signal.set(signal.get()+1), interval);
ac.signal.addEventListener("abort",
()=> setTimeout(()=> clearInterval(id), 2*interval));
setTimeout(()=> ac.abort(), 3*interval)
# Signal Actions: For Complex State
When working with objects, arrays, or other complex data structures. Signal Actions provide a structured way to modify state while maintaining reactivity.
Actions vs. Direct Mutation
✅ With Actions
const todos = S([], {
add(text) {
this.value.push(text);
// Subscribers notified automatically
}
});
// Use the action
S.action(todos, "add", "New todo");
❌ Without Actions
const todos = S([]);
// Directly mutating the array
const items = todos.get();
items.push("New todo");
// This WON’T trigger updates!
In some way, you can compare it with useReducer hook from React. So, the S(<data>, <actions>)
pattern creates a store “machine”. We can then invoke (dispatch) registered action by calling S.action(<signal>, <name>, ...<args>)
after the action call the signal calls all its listeners. This can be stopped by calling this.stopPropagation()
in the method representing the given action. As it can be seen in examples, the “store” value is available also in the function for given action (this.value
).
import { S } from "./esm-with-signals.js";
const signal= S(0, {
increaseOnlyOdd(add){
console.info(add);
if(add%2 === 0) return this.stopPropagation();
this.value+= add;
}
});
S.on(signal, console.log);
const oninterval= ()=>
S.action(signal, "increaseOnlyOdd", Math.floor(Math.random()*100));
const interval= 5*1000;
setTimeout(
clearInterval,
10*interval,
setInterval(oninterval, interval)
);
Actions provide these benefits:
- Encapsulate state change logic in named methods
- Guarantee notifications when state changes
- Prevent accidental direct mutations
- Act similar to reducers in other state management libraries
Here’s a more complete example of a todo list using signal actions:
import { S } from "./esm-with-signals.js";
const todos= S([], {
push(item){
this.value.push(S(item));
},
pop(){
const removed= this.value.pop();
if(removed) S.clear(removed);
},
[S.symbols.onclear](){ // this covers `S.clear(todos)`
S.clear(...this.value);
}
});
import { el, on } from "./esm-with-signals.js";
/** @type {ddeElementAddon<HTMLFormElement>} */
const onsubmit= on("submit", function(event){
event.preventDefault();
const data= new FormData(this);
switch (data.get("op")){
case "A"/*dd*/:
S.action(todos, "push", data.get("todo"));
break;
case "E"/*dit*/: {
const last= todos.get().at(-1);
if(!last) break;
last.set(data.get("todo"));
break;
}
case "R"/*emove*/:
S.action(todos, "pop");
break;
}
});
document.body.append(
el("ul").append(
S.el(todos, todos=>
todos.map(textContent=> el("li", textContent)))
),
el("form", null, onsubmit).append(
el("input", { type: "text", name: "todo", placeholder: "Todo’s text" }),
el(radio, { textContent: "Add", checked: true }),
el(radio, { textContent: "Edit last" }),
el(radio, { textContent: "Remove" }),
el("button", "Submit")
)
);
document.head.append(
el("style", "form{ display: flex; flex-flow: column nowrap; }")
);
function radio({ textContent, checked= false }){
return el("label").append(
el("input", { type: "radio", name: "op", value: textContent[0], checked }),
" ",textContent
)
}
Special Action Methods: Signal actions can implement special lifecycle hooks:
[S.symbols.onclear]()
- Called when the signal is cleared. Use it to clean up resources.
# Connecting Signals to the DOM
Signals really shine when connected to your UI. dd<el> provides several ways to bind signals to DOM elements:
Reactive Attributes
Bind signal values directly to element attributes, properties, or styles:
// Create a signal
const color = S("blue");
// Bind it to an element’s style
el("div", {
style: {
color, // Updates when signal changes
fontWeight: S(() => color.get() === "red" ? "bold" : "normal")
}
}, "This text changes color");
// Later:
color.set("red"); // UI updates automatically
Reactive Elements
Dynamically create or update elements based on signal values:
// Create an array signal
const items = S(["Apple", "Banana", "Cherry"]);
// Create a dynamic list that updates when items change
el("ul").append(
S.el(items, items =>
items.map(item => el("li", item))
)
);
// Later:
S.action(items, "push", "Dragonfruit"); // List updates automatically
The assign
and el
functions detect signals automatically and handle binding. You can use special properties like dataset
, ariaset
, and classList
for fine-grained control over specific attribute types.
import { S } from "./esm-with-signals.js";
const count= S(0);
import { el } from "./esm-with-signals.js";
document.body.append(
el("p", S(()=> "Currently: "+count.get())),
el("p", { classList: { red: S(()=> count.get()%2 === 0) }, dataset: { count }, textContent: "Attributes example" }),
);
document.head.append(
el("style", ".red { color: red; }")
);
const interval= 5 * 1000;
setTimeout(clearInterval, 10*interval,
setInterval(()=> count.set(count.get()+1), interval));
S.el()
is especially powerful for conditional rendering and lists:
import { S } from "./esm-with-signals.js";
const count= S(0, {
add(){ this.value= this.value + Math.round(Math.random()*10); }
});
const numbers= S([ count.get() ], {
push(next){ this.value.push(next); }
});
import { el } from "./esm-with-signals.js";
document.body.append(
S.el(count, count=> count%2
? el("p", "Last number is odd.")
: el()
),
el("p", "Lucky numbers:"),
el("ul").append(
S.el(numbers, numbers=> numbers.toReversed()
.map(n=> el("li", n)))
)
);
const interval= 5*1000;
setTimeout(clearInterval, 10*interval, setInterval(function(){
S.action(count, "add");
S.action(numbers, "push", count.get());
}, interval));
# Best Practices for Signals
Follow these guidelines to get the most out of signals:
- Keep signals small and focused: Use many small signals rather than a few large ones
- Use derived signals for computations: Don’t recompute values in multiple places
- Clean up signal subscriptions: Use AbortController (scope.host()) to prevent memory leaks
- Use actions for complex state: Don’t directly mutate objects or arrays in signals
- Avoid infinite loops: Be careful when one signal updates another in a subscription
While signals provide powerful reactivity for complex UI interactions, they’re not always necessary. A good approach is to started with variables/constants and when necessary, convert them to signals.
We can process form events without signals
This can be used when the form data doesn’t need to be reactive and we just waiting for results.
const onFormSubmit = on("submit", e => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// this can be sent to a server
// or processed locally
// e.g.: console.log(Object.fromEntries(formData))
});
// …
return el("form", null, onFormSubmit).append(
// …
);
We can use variables without signals
We use this when we dont’t need to reflect changes in the elsewhere (UI).
let canSubmit = false;
const onFormSubmit = on("submit", e => {
e.preventDefault();
if(!canSubmit) return; // some message
// …
});
const onAllowSubmit = on("click", e => {
canSubmit = true;
});
Using signals
We use this when we need to reflect changes for example in the UI (e.g. enable/disable buttons).
const canSubmit = S(false);
const onFormSubmit = on("submit", e => {
e.preventDefault();
// …
});
const onAllowSubmit = on("click", e => {
canSubmit.set(true);
});
return el("form", null, onFormSubmit).append(
// ...
el("button", { textContent: "Allow Submit", type: "button" }, onAllowSubmit),
el("button", { disabled: S(()=> !canSubmit), textContent: "Submit" })
);
Common Signal Pitfalls
- UI not updating when array/object changes
- Use signal actions instead of direct mutation
- UI not updating
- Ensure you passing the (correct) signal not its value (
signal
vssignal.get()
) - Infinite update loops
- Check for circular dependencies between signals
- Multiple elements updating unnecessarily
- Split large signals into smaller, more focused ones
# Mnemonic
S(<value>)
— signal: reactive valueS(()=> <computation>)
— read-only signal: reactive value dependent on calculation using other signalsS.on(<signal>, <listener>[, <options>])
— listen to the signal value changesS(<value>, <actions>)
— signal: pattern to create complex reactive objects/arraysS.action(<signal>, <action-name>, ...<action-arguments>)
— invoke an action for given signalS.el(<signal>, <function-returning-dom>)
— render partial dom structure (template) based on the current signal valueS.clear(...<signals>)
— off and clear signals (most of the time it is not needed as reactive attributes and elements are handled automatically)