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:

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:

  1. Keep signals small and focused: Use many small signals rather than a few large ones
  2. Use derived signals for computations: Don’t recompute values in multiple places
  3. Clean up signal subscriptions: Use AbortController (scope.host()) to prevent memory leaks
  4. Use actions for complex state: Don’t directly mutate objects or arrays in signals
  5. 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 vs signal.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 value
  • S(()=> <computation>) — read-only signal: reactive value dependent on calculation using other signals
  • S.on(<signal>, <listener>[, <options>]) — listen to the signal value changes
  • S(<value>, <actions>) — signal: pattern to create complex reactive objects/arrays
  • S.action(<signal>, <action-name>, ...<action-arguments>) — invoke an action for given signal
  • S.el(<signal>, <function-returning-dom>) — render partial dom structure (template) based on the current signal value
  • S.clear(...<signals>) — off and clear signals (most of the time it is not needed as reactive attributes and elements are handled automatically)