`deka-dom-el` — Observables and reactivity

Handling reactivity in UI via observables.

Using observables to manage reactivity

How a program responds to variable data or user interactions is one of the fundamental problems of programming. If we desire to solve the issue in a declarative manner, observables may be a viable approach.

// use NPM or for example https://cdn.jsdelivr.net/gh/jaandrle/deka-dom-el/dist/esm-with-observables.js import { O, observable } from "deka-dom-el/observables"; O===observable /** @type {ddeObservable} */ /** @type {ddeAction} */ /** @type {ddeActions} */

# Introducing observables

Using observables, we split program logic into the three parts. Firstly (α), we create a variable (constant) representing reactive value. Somewhere later, we can register (β) a logic reacting to the observable value changes. Similarly, in a remaining part (γ), we can update the observable value.

import { O } from "./esm-with-observables.js"; // α — `observable` represents a reactive value const observable= O(0); // β — just reacts on observable changes O.on(observable, console.log); // γ — just updates the value const update= ()=> observable(observable()+1); update(); const interval= 5*1000; setTimeout(clearInterval, 10*interval, setInterval(update, interval));

All this is just an example of Event-driven programming and Publish–subscribe pattern (compare for example with fpubsub library). All three parts can be in some manner independent and still connected to the same reactive entity.

Observables are implemented in the library as functions. To see current value of observable, just call it without any arguments console.log(observable()). To update the observable value, pass any argument observable('a new value'). For listenning the observable value changes, use O.on(observable, console.log).

Similarly to the on function to register DOM events listener. You can use AbortController/AbortSignal to off/stop listenning. In example, you also found the way for representing “live” piece of code computation pattern (derived observable):

import { O } from "./esm-with-observables.js"; const observable= O(0); // computation pattern const double= O(()=> 2*observable()); const ac= new AbortController(); O.on(observable, v=> console.log("observable", v), { signal: ac.signal }); O.on(double, v=> console.log("double", v), { signal: ac.signal }); observable(observable()+1); const interval= 5 * 1000; const id= setInterval(()=> observable(observable()+1), interval); ac.signal.addEventListener("abort", ()=> setTimeout(()=> clearInterval(id), 2*interval)); setTimeout(()=> ac.abort(), 3*interval)

# Observables and actions

O(/* primitive */) allows you to declare simple reactive variables, typically around immutable primitive types. However, it may also be necessary to use reactive arrays, objects, or other complex reactive structures.

import { O } from "./esm-with-observables.js"; const observable= O(0, { increaseOnlyOdd(add){ console.info(add); if(add%2 === 0) return this.stopPropagation(); this.value+= add; } }); O.on(observable, console.log); const oninterval= ()=> O.action(observable, "increaseOnlyOdd", Math.floor(Math.random()*100)); const interval= 5*1000; setTimeout( clearInterval, 10*interval, setInterval(oninterval, interval) );

…but typical user-case is object/array (maps, sets and other mutable objects):

import { O } from "./esm-with-observables.js"; const todos= O([], { push(item){ this.value.push(O(item)); }, pop(){ const removed= this.value.pop(); if(removed) O.clear(removed); }, [O.symbols.onclear](){ // this covers `O.clear(todos)` O.clear(...this.value); } }); import { el, on } from "./esm-with-observables.js"; /** @type {ddeElementAddon<HTMLFormElement>} */ const onsubmit= on("submit", function(event){ event.preventDefault(); const data= new FormData(this); switch (data.get("op")){ case "A"/*dd*/: O.action(todos, "push", data.get("todo")); break; case "E"/*dit*/: { const last= todos().at(-1); if(!last) break; last(data.get("todo")); break; } case "R"/*emove*/: O.action(todos, "pop"); break; } }); document.body.append( el("ul").append( O.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 ) }

In some way, you can compare it with useReducer hook from React. So, the O(<data>, <actions>) pattern creates a store “machine”. We can then invoke (dispatch) registered action by calling O.action(<observable>, <name>, ...<args>) after the action call the observable 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).

# Reactive DOM attributes and elements

There are on basic level two distinc situation to mirror dynamic value into the DOM/UI

  1. to change some attribute(s) of existing element(s)
  2. to generate elements itself dynamically – this covers conditions and loops
import { O } from "./esm-with-observables.js"; const count= O(0); import { el } from "./esm-with-observables.js"; document.body.append( el("p", O(()=> "Currently: "+count())), el("p", { classList: { red: O(()=> count()%2) }, dataset: { count }, textContent: "Attributes example" }) ); document.head.append( el("style", ".red { color: red; }") ); const interval= 5 * 1000; setTimeout(clearInterval, 10*interval, setInterval(()=> count(count()+1), interval));

To derived attribute based on value of observable variable just use the observable as a value of the attribute (assign(element, { attribute: O('value') })). assign/el provides ways to glue reactive attributes/classes more granularly into the DOM. Just use dedicated build-in attributes dataset, ariaset and classList.

For computation, you can use the “derived observable” (see above) like assign(element, { textContent: O(()=> 'Hello '+WorldObservable()) }). This is read-only observable its value is computed based on given function and updated when any observable used in the function changes.

To represent part of the template filled dynamically based on the observable value use O.el(observable, DOMgenerator). This was already used in the todo example above or see:

import { O } from "./esm-with-observables.js"; const count= O(0, { add(){ this.value= this.value + Math.round(Math.random()*10); } }); const numbers= O([ count() ], { push(next){ this.value.push(next); } }); import { el } from "./esm-with-observables.js"; document.body.append( O.el(count, count=> count%2 ? el("p", "Last number is odd.") : el() ), el("p", "Lucky numbers:"), el("ul").append( O.el(numbers, numbers=> numbers.toReversed() .map(n=> el("li", n))) ) ); const interval= 5*1000; setTimeout(clearInterval, 10*interval, setInterval(function(){ O.action(count, "add"); O.action(numbers, "push", count()); }, interval));

# Mnemonic