Making DOM Text Nodes Reactive
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 usedefineProperty
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:
Using the
value
property to directly define a value for the property, orUsing a
getter
and asetter
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
Instead of assigning a primitive string to
textContent
, we want to be able to assign an object, let’s call itreactiveTextData
, that object will have acontent
property.We want to create a “reactive binding” where, whenever we change (assign a different value to) the value of
content
ofreactiveTextData
- every element that has atextContent
withreactiveTextData
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 asetter
function, because if you define a setter for thecontent
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
, andA
setter
that will assign the value to_content
and will also assign the value to alltextNodeSubscribers
- 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:
Make sure it’s reactive, if not - make it reactive.
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.