Skip to main content

Reactivity in JavaScript

· 13 min read

Javascript reactivity

These days, all mainstream JavaScript frameworks have reactivity built-in. Understanding basic principals of reactivity behind fancy implementations could be helpful while tackling complex ideas and/or design decisions for a particular framework or library. In this post, we'll be talking about reactivity of interactive user interfaces and specifically reactivity of the UI bound state.

Definition

Reactivity in general could be described as the ability of tracking reactive atoms and the propagation of their change through wrapping their execution in computed expressions. In case of the state reactivity, reactive atom is the state (or portions of the state) that is bound to the UI.

Parts

Before getting into details of the implementation, it's worth understanding basic components we are playing with. Here is the list of bare minimum parts for the reactive state:

state - the state itself, could be represented as an in-memory variable

getter - method that returns current state value

setter - method that sets value of the current state

action - method that reflects UI changes

Here is how these components look like in code:

let state = 'state';

function getter() {
return state;
}

function setter(value) {
state = value;
}

function action() {
const value = getter();
document.getElementById('placeholder').innerHTML = value;
}

Interactions

These components do not enable any reactivity by themselves. Reactivity part comes out of their interactions. When the view changes, it invokes the setter bound to the specific change. Setter changes current state value. An action is triggered upon the state change that updates the view by reflecting latest state changes using a specific getter.

            ------
| VIEW |
-------- / ------ \ -------
| ACTION | | SETTER |
-------- STATE -------
\ /
--------
| GETTER |
--------

The most important part here is the ability of the action to trigger or to be triggered upon changes in the state (by a specific setter). That is what makes interactions reactive. Let's see how we can add a bit of a glue to make it happen.

Wrapper

As for a glue, we can use some sort of a wrapper that wraps the execution in a computed expression. How about we take this idea literally and wrap the setter into a function (computed expression) that executes the setter and also executes the action that is supposed to be triggered upon setter execution. We are going to make things slightly more complicated by preserving multiple actions for a specific setter, so we can simulate multiple parts of the view changing at the same time:

let state = 'state';

function getter() {
return state;
}

function setter(value) {
state = value;
}

function action() {
const value = getter();
document.getElementById('placeholder').innerHTML = value;
}

const ComputeItemNaive = {
add(action) {
this.actions.push(action);
},
wrap(value) {
return function () {
value(...arguments);
this.actions.forEach(item => {
item();
});
}.bind(this);
},
create() {
return Object.assign({}, ComputeItemNaive, { actions: [] });
}
};

So the wrap method returns a new function that invokes parameter value with provided arguments along with all the prior actions preserved using the add method.

And this is how we simulate the actual work:

function run() {
const computeItem = ComputeItemNaive.create();
computeItem.add(action);

const wrapper = computeItem.wrap(setter);

[...Array(6).keys()].forEach(item => {
setTimeout(() => wrapper('state ' + item), item * 1000);
});
}

run();

Anchors

The wrapper implementation works but is has limitations.

  • First, with the number of actions per specific setter increasing, view becomes more sluggish (it has to wait until all actions are completed). This could be mitigated by async actions, but it is a responsibility of the action creator though. We would like action creator to have as seamless experience as possible.
  • Second, the setter cannot be used by itself, it always has to be wrapped, and the wrapper behaves as a layer between state changes and action executions. It would be nicer experience if the setter was invoked by itself and that would lead to all the attached actions executed. We can address both of the problems by using the anchors technique.

Anchors techniques reverses the responsibility of the action invoker from the wrapper to the setter itself. The same goes for the getter. It pulls current action and sets its trigger within the action computations sequence to keep the view consistent with the actions performed in time. To make things scale, all the computations are executed in an async fashion.

Here is how compute item looks like now:

const ComputeItem = {
id: 0,
pendingComputations: [],
currentComputation: null,

invalidate() {
ComputeItem.pendingComputations.push(this);

if (this.computation) {
this.computation();
}

this.requireFlush();
},

onInvalidate(f) {
this.computation = f;
},

requireFlush() {
setTimeout(() => {
while (ComputeItem.pendingComputations.length) {
ComputeItem.pendingComputations.shift().compute();
}
}, 0);
},

compute() {
const current = ComputeItem.currentComputation;
ComputeItem.currentComputation = this;
this.action();
ComputeItem.currentComputation = current;
},

create(execute) {
const result = Object.assign({}, ComputeItem,
{
computation: null,
id: ComputeItem.id++,
action: execute
});

ComputeItem.currentComputation = result;
return result;
}
}

We went with a static list of pending computations that is filled-in upon changes being triggered (queued). We also have a static current computation property to identify priorities of the actions under execution. Every compute item has its own id assigned during creation, so it could found by address and manipulated within the list of computations. As for the interface, compute item exposes invalidate, onInvalidate, requireFlush and compute methods (create is just a facility method). It's worth noticing that compute item has the ability to hold a so called sub-computation referenced by a computation property. Form the compute item point of view it is just a computation expression that is executed upon the invalidation of all pending computations.

invalidate method adds the compute item itself to the static list of pending commutations, checks if there is any sub-computation available, executes it, and executes the rest of pending computations via requireFlush method.

onInvalidate method simply attaches a sub-computation to a computation.

requireFlush method executes all the pending computations (compute items) via invoking their compute method respectively.

compute method preserves current computation (to not to mess around with the sequence), sets current computation to be the compute item itself and executes its action (specified by the create method during construction, in a similar fashion wrapper was holding the pointer to the setter in the previous example).

The second part of the implementation decouples action executors (compute items) from action schedulers (triggers) just so the end consumer does not have to, leaving the room to the simpler anchor API usage:

const Dependency = {
depend() {
const current = ComputeItem.currentComputation;

if (!current) return;

this.computations[current.id] = current;

current.onInvalidate(() => {
delete this.computations[current.id];
});
},

change() {
for (let key in this.computations) {
this.computations[key].invalidate();
}
},

action(execute) {
const computation = ComputeItem.create(execute);
computation.compute();
},

create() {
return Object.assign({}, Dependency, { computations: {} });
}
}

Dependency represents a connection between the point of action execution and scheduling. In its interface, it exposes change, depend and action methods.

change method represents an action trigger (should be invoked within the setter body). It invalidates all the available computations.

depend method preserves current computation and makes sure of removing it from the list during next invalidation cycle (this one will be used by the getter).

action method creates a compute item and triggers its action via the compute method, that effectively starts the reactivity cycle spinning.

And the usage example:

let state = 'apple';
const dependency = Dependency.create();

function getter() {
dependency.depend();
return state;
}

function getter2() {
let value = getter().split('').reverse().join('');
return value;
}

function setter(value) {
if (state === value) return;

state = value;
dependency.change();
}

function action() {
let value = getter();
let placeholder = document.getElementById('placeholder');
if(placeholder) {
placeholder.innerHTML = value;
}
}

function action2() {
let value = getter2();
let placeholder = document.getElementById('placeholder2');
if(placeholder) {
placeholder.innerHTML = value;
}
}

Dependency.action(action2);
Dependency.action(action);

function run() {
[...Array(6).keys()].forEach(item => {
setTimeout(() => setter('banana ' + item), (item + 1) * 1000);
});
}
run()

The example contains two getters: the actual getter that invokes dependency cycle hence, sets up current computation, and a fake getter that uses the actual one just to simulate computed property behavior. The setter triggers the change cycle, where two actions simulate two separate parts of a view being changed.

Action scheduling is executed first for both actions to have computed items ready by the time execution starts.

Dependency.action(action2);
Dependency.action(action);

Reactive dictionary

Solving one problem exposes another one. At the moment our state is just a variable. In a real world applications, state is a lot more advanced tree of objects connected by properties they expose representing any in-memory data structure. We can try changing a string variable for an object to specify more sophisticated state shape and get some benefits out of it, but the setter still works with the state in one piece. This leads to unnecessary re-renders and performance losses.

We can take another route though. Instead of the state object controlled by one state change cycle, we can wrap (wrapper idea again) the object into something that tracks changes of its properties and triggers different dependency cycles (depending on a property). Since JavaScript objects are collections of properties or, more precisely, collections of key - value pairs, where a pair could be refereed to by its key in one step, we can mimic a dictionary like interface that gives us access to properties and trigger dependency cycles where needed. Combining the idea of a dictionary with the idea of invoking change triggers seamlessly for the caller, gives us so called reactive dictionary implementation.

Reactive dictionary "stores an arbitrary set of key-value pairs. Each key is individually reactive such that calling set for a key will invalidate any Computations that called get with that key, according to the usual contract for reactive data sources."

The implementation itself is simple, assuming we keep all the previous implementations around Dependency and ComputeItem the same.

const ReactiveDictionary = {
get(key) {
let dependency = this.keyDependencies[key];
if (!dependency) {
dependency = Dependency.create();
this.keyDependencies[key] = dependency;
}
dependency.depend();
return this.keys[key];
},
set(key, value) {
this.keys[key] = value;
let dependency = this.keyDependencies[key];
if (dependency) {
dependency.change();
}
},
create() {
return Object.assign({}, ReactiveDictionary, {
keyDependencies: {},
keys: {}
});
}
}

Every key of the dictionary gets its own dependency seamlessly for the caller.

let reactiveDictionary = ReactiveDictionary.create();

function action() {
let value = reactiveDictionary.get('test');
let placeholder = document.getElementById('placeholder');
if(placeholder) {
placeholder.innerHTML = value;
}
}
function action2() {
let value = reactiveDictionary.get('test 2');
let placeholder = document.getElementById('placeholder2');
if(placeholder) {
placeholder.innerHTML = value;
}
}

Dependency.action(action);
Dependency.action(action2);

function run() {
[...Array(10).keys()].forEach(item => {
setTimeout(() => {
if (item < 5)
reactiveDictionary.set('test', 'RD value ' + item);
else
reactiveDictionary.set('test 2', 'RD value ' + item);
}, item * 1000);
});
}

run();

If we are not concerned with compatibility issues, we can make the interface even simpler and more component looking like by using JavaScript Proxy class.

const Library = {
createClass(obj) {
const keyDependencies = {};
const props = obj.properties;

const newProps = new Proxy(props, {
get: function(target, name) {

let dependency = keyDependencies[name];

if(!dependency) {
dependency = Dependency.create();
keyDependencies[name] = dependency;
}

dependency.depend();

return target[name];

},
set: function(obj, prop, value) {
obj[prop] = value;

let dependency = keyDependencies[prop];

if(dependency) {
dependency.change();
}
return true;
}
});

obj.properties = newProps;
obj.render = obj.render.bind(obj);

Dependency.action(obj.render);

return obj;
}
}

const obj = Library.createClass({
properties: {
one: 1,
two: '2'
},
render() {
let value = this.properties.one;

let placeholder = document.getElementById('placeholder');
if(placeholder) {
placeholder.innerHTML = value;
}
value = this.properties.two;
placeholder = document.getElementById('placeholder2');
if(placeholder) {
placeholder.innerHTML = value;
}
}
});

function run() {
[...Array(10).keys()].forEach(item =>
setTimeout(() => {
if (item < 5)
obj.properties.one = item;
else
obj.properties.two = item;
}, item * 1000));
}

run()

VDOM

We got to the point where render method is executed when any of the component properties change. From the reactivity point of view that is a pretty good result. But what if we could spice thins up a little bit more?

The render method itself manipulates HTML DOM directly. This approach does not scale without using help from some sort of a library. We could go with templating, but virtual DOM approach is also possible. With the help of a virtual-dom package, DOM manipulations could be implemented in one method:

const h = require('virtual-dom/h');
const diff = require('virtual-dom/diff');
const patch = require('virtual-dom/patch');
const createElement = require('virtual-dom/create-element');

let tree;
let rootNode;

function renderh(newTree) {

if(!tree) {
tree = newTree;
rootNode = createElement(newTree);
document.body.appendChild(rootNode);
return;
}

const patches = diff(tree, newTree);
rootNode = patch(rootNode, patches);
tree = newTree;
}

const initialTree = h('div', null, ["Hello! ",
h('br'),
h('input', {type: "text", value: ''}),
h('button', null, ["Click me"])
]);

renderh(initialTree);

Using VDOM within a component is just a matter of calling renderh within the render wrapper body:

const Library = {
createClass(obj) {

let keyDependencies = {};
let props = obj.properties;

let newProps = new Proxy(props, {
get: function(target, name) {

let dependency = keyDependencies[name];

if(!dependency) {
dependency = Dependency.create();
keyDependencies[name] = dependency;
}

dependency.depend();

return target[name];

},
set: function(obj, prop, value) {
obj[prop] = value;

let dependency = keyDependencies[prop];

if(dependency) {
dependency.change();
}
}
});

obj.properties = newProps;

let render = obj.render.bind(obj);

obj.render = function() {
let tree = render();
renderh(tree); // VDOM update
};

Dependency.action(obj.render);

return obj;
}
}

const obj = Library.createClass({
properties : {
one : 1,
two : "2"
},
render() {

return h('div', null, ["Hello! ", this.properties.one,
h('br'),
h('input', {type: "text", value: this.properties.two}),
h('br'),
h('button', null, ["Click me"])
]);
}
});

[...Array(10).keys()].forEach(item =>{

setTimeout(() => {
if(item < 5)
obj.properties.one = item;
else
obj.properties.two = item;

}, item * 1000);
})

Final result

Finally the example that combines all the pieces together:

const obj = Library.createClass({
properties : {
one : 1,
two : '2',
text : ''
},

onClick() {
this.properties.text = ' : ' + this.properties.two;
},
onChange(event) {
this.properties.two = event.target.value;
this.properties.text = ' : ' + this.properties.two;
},

render() {

return (
<div>Hello! {this.properties.one}
<br/>
<input type="text" value={this.properties.two}
onchange={this.onChange.bind(this)} />
<br/>
<button onclick={this.onClick.bind(this)}>Click</button>
<div>{this.properties.text}</div>
</div>
);
}
});

[...Array(10).keys()].forEach(item =>{

setTimeout(() => {
if(item < 5)
obj.properties.one = item;
else
obj.properties.two = item;
}, item * 1000);
})

Practical example

Can we take this approach any further and use it for a more realistic use case? Well, the difference is only in the amount of properties involved in data binding. Taking for example this code pen, we can recreate it using JSX and data binding as apposite to original JQuery based DOM management.

Working example sources cloud be found here. All the original designs are preserved showing that our little reactive data binding library is not that little after all.

Credit card demo

Source code

Reference implementation

References

Exploring the state of reactivity patterns in 2020

JavaScript object

Reactive dictionary

JavaScript Proxy class

Virtual DOM

virtual-dom package

babel-plugin-transform-jsx plugin

Credit card code pen