Let's go reactive with VanillaJS.
My last blog about ditching state managers made me think about Vue's ref
and SolidJS signals
that are so hot right now that even Angular made their own signal
implementation. But do we really need a fancy framework to jump on this craze or can we achieve something with just vanilla
javascript
?
Well look no further because this time we will create a framework of our own called OdéJS
!
Here comes Odé!
Javascript has this thing called Proxies, but reading the documentation it doesn't really sound like it was designed for this job. With proxies you can validate new values or format values when they are requested, for example. I happen to know that Vue uses proxies
for reactivity so that's enough proof for me!
In a nutshell when we create data driven apps, we want the UI to change when the data changes. Now if we set a function to react when a data property changes in a object, we have achiveved our goals! Simple!
const query = document.querySelector.bind(document)
function createProxy(initialValue, onChange) {
// the first argument is the target object where
// we set our initialValue
// these properties could be named anything and there
// could be as many as we want but this way we look like
// Vue's refs or SolidJS signals.
return new Proxy(
{
value: initialValue,
},
{
// this function is always called by
// the Proxy whenever the target object
// value changes
set(obj, prop, value) {
// we only care about value here
if (prop === 'value') {
// actually set the value to the target object
obj[prop] = value;
// call our callback
onChange(obj[prop])
}
// return true if you accept the incoming value
return true;
}
}
)
}
const seconds = createProxy(0, (val) => {
// update the DOM
query('#seconds').innerHTML = val;
})
const loop = () => {
seconds.value += 1;
setTimeout(() => loop(), 1000)
}
loop()
We update the DOM only when the data changes and we update it precisely where it needs to. Surely there can't be more efficient way than this?
The above is a very simple example and it only allows for a single listener for when the data changes. We want to work with multiple modules and have multiple listeners. We also want to be able to remove listeners.
So let's create a module for our createProxy
function and add the query
function with it so we can now call it a framework.
export const query = document.querySelector.bind(document)
/**
* This time only a initialValue is passed to the function
*
* @param {any} initialValue
* @returns {Proxy}
*/
export function createProxy(initialValue) {
// this is where we gather all the onChange callbacks
const callbacks = [];
return new Proxy(
{
value: initialValue,
// register a callback on when the value changes
onChange(callback) {
const id = callbacks.length + 1;
callbacks.push({
id,
callback,
})
// when a callback is registered, return a function
// that removes the callback using the id set above
return () => {
const index = callbacks.findIndex(row => row.id === id);
callbacks.splice(index, 1);
}
},
},
{
set(obj, prop, value) {
if (prop === 'value') {
obj[prop] = value;
// just like before but now we have an array
// full of callback functions
for (let onChange of callbacks) {
onChange.callback(obj[prop])
}
}
return true;
}
}
)
}
So now we have a great framework to implement the simple example with:
import { createProxy, query } from './ode.js'
const seconds = createProxy(0)
seconds.onChange((val) => {
// update the DOM
query('#seconds').innerHTML = val;
})
const loop = () => {
seconds.value += 1;
setTimeout(() => loop(), 1000)
}
loop()
Looks pretty nice and we could export the const seconds
and register another callback in there!
Conclusion
The obvious downfall with this is when we start using arrays or objects, because they would not work like this. Adding or removing items from an array will not trigger the proxy set
function. For arrays we'd have to create our own Array
implementation that overrides the original. With objects we could either register a proxy for every property or make another proxy implementation where the object is the target itself and the callback gets the value and the property name that changes. That is why Vue and others have much more complicated implementations for things like these to work in the real life.
I encourage you to try and create your own version, but I wouldn't recommend to use this or that in production.
Here's a more complex demo: