Making DOM Text Nodes Reactive

·

10 min read

Introduction

One of the main aspects of popular client-side frameworks is reactivity, which means a “binding” between two variables: whenever one value changes, the other is automatically adjusted. This binding is usually one-way in the context of these frameworks, where a certain variable represents the “source of truth” or the “state,” and the other variable is bound to it. The implementation of this mechanism is known as Signals and is also recognized as the Observer Pattern in design patterns. In this pattern, multiple “variables” can be bound to a single source of truth. These variables act as “listeners” or “subscribers,” and by changing the state value, all subscribers automatically update to reflect the change. This concept is also referred to as Publish/Subscribe (or Pub/Sub) in software architecture.

Why do we need it?

One of the underlying mechanisms for web UIs is the DOM (Document Object Model) API—a memory representation of the visual blocks rendered on the page. This API was not created with reactivity in mind. As web apps have become more sophisticated, UIs now frequently change their visual aspects, requiring dynamic re-rendering.

While JavaScript allows assignment by reference, this is limited to complete objects.

const obj1 = { value: "hello" };
const ob2 = obj1;

Both obj1 and obj2 reference the same object in memory. If you change a property of obj1, the same property in obj2 will change, because they point to the same object.

obj1.value = "world";
// obj2.value also becomes "world"

However, if you assign an object’s primitive property value (like a string) to another variable, it is passed by value, meaning - no reference to the original is maintained.

Meta Programming

Meta-programming is a term used to describe mechanisms that allow developers to modify or override certain native behaviors of a programming language.

In JavaScript, there are two primary ways to alter object behaviors:

Proxy API

The Proxy API is a “wrapper” class that accepts an object and a Handler object as arguments. The handler can define traps that modify how standard operations (e.g., property assignment, deletion, etc.) are handled for that object.
We won’t use or discuss this API further in this article.

Object.defineProperty(s)

The other is the static Object methods: defineProperty and defineProperties, which provides a “lower-level” way to define properties, beyond simply assigning a value.
Using these methods involves passing a “descriptor” object, which describes aspects of the property:

  • Is it writable or “readonly”?

  • Is it “configurable”? Meaning, can you use defineProperty on it again?)

  • Is it enumerable? Meaning, will its value appear when iterating over the keys/values of the object?

Then there’s the actual value of the property, and here we have two flavours:

  1. Using the value property to directly define a value for the property, or

  2. Using a getter and a setter function. Using those - is also a mechanism to override the native logic for when setting a value to a property, or getting a value. The difference from using the Proxy API is that with `Object.defineProperty` we define it per specific properties, where the Proxy API is a “wrapper” around entire objects.

Many popular client-side frameworks such as VUE or Solid uses one of these mechanisms, or a combination of both to achieve reactivity and implement “Signals”.

In the next sections, we are going to demonstrate how to use Object.defineProperties / Object.defineProperty and custom setters/getters to override the way you set the textual content of elements in the DOM, and make it reactive, in a simplified demonstrative way, including 3 steps.

The Goal

Given these two divs:

<div id="div1"></div>
<div id="div2"></div>

and an object:

const textData = {
    content: "This is a content"
}

If we assign the value of the content property to the textContent of both divs:

const $div1 = document.getElementById('div1');
const $div2 = document.getElementById('div2');
$div1.textContent = textData.content;
$div2.textContent = textData.content;

Both divs will contain and display the text string:

<div id="div1">This is a content</div>
<div id="div2">This is a content</div>

What actually happens is: the DOM API creates a Text Node instance with the textual value we assigned, inside of each div.

If we change the textData object:

textData.content = "This is another content";`

Nothing will change on the divs, of-course. Once we assigned a primitive string to their textContent - those strings are now part of each of the DOM element’s textual content, they are saved “by value”. There is no connection or binding between them and textData or textData.content.

To change the text of any of the Text Nodes, we’ll need to assign a new value to the element’s textContent again.

What we want to achieve

  1. Instead of assigning a primitive string to textContent, we want to be able to assign an object, let’s call it reactiveTextData, that object will have a content property.

  2. We want to create a “reactive binding” where, whenever we change (assign a different value to) the value of content of reactiveTextData - every element that has a textContent with reactiveTextData assigned to it - will have its textual content re-rendered to reflect the change.

Implementation

Making a reactive object

Let’s write a function that accepts a “normal” object, and makes it “reactive” to our need:

function makeReactive(reactiveData) {

Checking if it’s already reactive

When the function turns an object “reactive”, it will add a “private” boolean isReactive property to it, set to true. So the first thing the function will do is to check if the passed object is already reactive, and if it does, it will simply return nothing.

 if (reactiveData.hasOwnProperty('isReactive') 
&& reactiveData.isReactive === true) return;

If it’s not already reactive, we’ll continue.

The next thing will be to save the value of the content property, as we are going to assign it later as the default content.

 const initialContent = reactiveData.content;

Defining a getter, a setter, and “subscribers”

We are going to do all of these, using a single call to Object.defineProperties on the reactiveData, this will allow us to define the required properties and methods. We will define:

  • isReactive: the “private” property that marks the object as “reactive”.

  • textNodeSubscribers: this will be a “private” set of all Text Nodes “subscribed” to the object.

  • _content: this will be a “private” property to actually contain the value for content. We need this when we create a setter function, because if you define a setter for the content property and change it directly from within the setter, it will trigger a call for the setter function again, which will trigger the setter again and again, leading to a Stack Overflow exception to be thrown.

  • content: for this we will create:

    • A getter that will simply return the value of _content, and

    • A setter that will assign the value to _content and will also assign the value to all textNodeSubscribers - this will be the major part of the reactivity.

Object.defineProperties(reactiveData, {
    // Flag to mark the object as "reactive"
    'isReactive': {
      value: true,
      writable: false,
      configurable: false,
      enumerable: false
    },
    // A set to hold the Text Node "subscribers"
    'textNodeSubscribers': {
      value: new Set(),
      configurable: false,
      enumerable: false,
      writable: true
    },
    // A "private" property to hold the actual content
    '_content': {
      value: initialContent,
      configurable: false,
      enumerable: false,
      writable: true
    },
    'content': {
      get() {
        return this._content;
      },
      set(value) {
        this._content = value;
        // The Node interface has a nodeValue property,
        // assigning to it - will change the text for it.
        this.textNodeSubscribers.forEach(textNode=> 
textNode.nodeValue = this._content);
      }
    }
  });
}

Defining a ReactiveTextNode class

Whenever a reactiveData object is assigned to textContent we’ll need to:

  1. Make sure it’s reactive, if not - make it reactive.

  2. Add the Text Node as a subscriber to the reactiveData.

One way to do that is to put that logic in the constructor of a new class that extends the Text class (which represents a Text Node).

class ReactiveTextNode extends Text {
  constructor(reactiveData) {
    // the reactiveData object, must contain a "content" property,
    // throw an error if it doesn't
    if (!reactiveData.hasOwnProperty('content')) 
        throw Error("Reactive Data object must containt a content property!");

    // call the super constructor with the content value,
    // this will actually create the Text Node instance.
    super(reactiveData.content);

    // Make the reactiveData object reactive.
    // If it's already reactive - the function will return immediately.
    makeReactive(reactiveData);

    // Adds *this* Text Node instance to the reactiveData "subscribers"
    reactiveData.textNodeSubscribers.add(this);
  }
}

Overriding textContent assignment logic

The last part is to have textContent be able to support being assigned a reactiveData object. To do that, we will use Object.defineProperty on the prototype of the HTMLDivElement class (to have this behavior applicable to ALL div instances).

We will define a setter function. The setter function will create a new ReactiveTextNode instance, passing the assigned reactiveData object to the ReactiveTextNode's constructor. We will also save the ReactiveTextNode instance on the HTMLDivElement instance on a property we’ll call r_textNode.
If a Text Node (a ReactiveTextNode instance) already exists on the element, we should only set its nodeValue value to a new ReactiveTextNode instance with the new reactiveData, so we’ll have to check if it already exists first.

Object.defineProperty(HTMLDivElement.prototype, "textContent", 
{ 
    set(reactiveData) {
        // If there's already a ReactiveTextNode instance,
        // sets its value to a new one.
        if (this.hasOwnProperty('r_textNode')) { 
            this.r_textNode.nodeValue = 
               new ReactiveTextNode(reactiveData); 
        }
        // Otherwise, create a new one, 
        // set its value to a new ReactiveTextNode.
        else { 
            const r_textNode = new ReactiveTextNode(reactiveData); 
            this.r_textNode = r_textNode; 
            this.appendChild(r_textNode); 
        } 
    } 
});

Testing

Now, if we have references to the divs: $div1, $div2, we can set a “reactiveTextData" object straight to their textContent

const reactiveTextData = {
    content: "This is a content"
}

$div1.textContent = reactiveTextData;
$div2.textContent = reactiveTextData;

Both divs should now look something like this:

Now, if we set the content property to a different string, the texts for both divs should change to the different string. Let’s put it inside a 10 seconds timeout:

setTimeout(()=> reactiveTextData.content = "New Content!", 10000);

Full example

Here is the full code example:

function makeReactive(reactiveData) {
    // If reactiveData is already reactive, do nothing and return.
    if (reactiveData.hasOwnProperty('isReactive') 
&& reactiveData.isReactive === true) return;
    // Save the value of content, to use as a default value for the
    // private _content
    const initialContent = reactiveData.content;
    Object.defineProperties(reactiveData, {
        // Flag to mark the object as "reactive"
        'isReactive': {
          value: true,
          writable: false,
          configurable: false,
          enumerable: false
        },
        // A set to hold the Text Node "subscribers"
        'textNodeSubscribers': {
          value: new Set(),
          configurable: false,
          enumerable: false,
          writable: true
        },
        // A "private" property to hold the actual content
        '_content': {
          value: initialContent,
          configurable: false,
          enumerable: false,
          writable: true
        },
        'content': {
          get() {
            return this._content;
          },
          set(value) {
            this._content = value;
            // The Node interface has a nodeValue property,
            // assigning to it - will change the text for it.
            this.textNodeSubscribers.forEach(textNode=> 
               textNode.nodeValue = this._content);
          }
        }
    });
}

/* --- */

class ReactiveTextNode extends Text {
  constructor(reactiveData) {
    // the reactiveData object, must contain a "content" property,
    // throw an error if it doesn't
    if (!reactiveData.hasOwnProperty('content')) throw Error("Reactive Data object must containt a content property!");

    // call the super constructor with the content value,
    // this will actually create the Text Node instance.
    super(reactiveData.content);

    // Make the reactiveData object reactive.
    // If it's already reactive - the function will return immediately.
    makeReactive(reactiveData);

    // Adds *this* Text Node instance to the reactiveData "subscribers"
    reactiveData.textNodeSubscribers.add(this);
  }
}

/* --- */

Object.defineProperty(HTMLDivElement.prototype, "textContent", 
{ 
    set(reactiveData) {
        // If there's already a ReactiveTextNode instance,
        // set its value to a new one.
        if (this.hasOwnProperty('r_textNode')) { 
            this.r_textNode.nodeValue = new ReactiveTextNode(reactiveData); 
        }
        // Otherwise, create a new one, set
        else { 
            const r_textNode = new ReactiveTextNode(reactiveData); 
            this.r_textNode = r_textNode; 
            this.appendChild(r_textNode); 
        } 
    } 
});

Codepen

Here is a codepen with a full example of what was described in this article.

Summary

This is a simplified and specific example - showcasing how to implement the concept of reactivity into the native DOM API. While frameworks use similar concepts, there are many more challenges involved. One example for an obvious required additional feature, is the ability to “unsubscribe” from the listeners set (e.g. if a DOM element is removed from the DOM tree).

For further information, read the official proposition for adding native Signals to Javascript.

For example, you can see in the proposition how it is suggested to only recompute a changed signal once it is read again (marking it “dirty” until it does).

See also

You can also read another article of mine discussing similar subjects, showcasing reactivity implementation, this time using the Proxy API and Template literal Strings.