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.
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:
- our fancy library component
- VDOM
- babel-plugin-transform-jsx plugin for JSX usage instead of the
h
helper - webpack
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.
Source code
References
Exploring the state of reactivity patterns in 2020