Monitoring Variables for Changes

All frameworks have this ability, but what if you wanted to do it in plain JavaScript? In this tutorial, we will explore how to track the state of your application and update the DOM on the fly.

In a project I am currently working on, I cannot use any frameworks due to the nature of the environment it will run in. So I went about making a traditionally built site however, I have been using Svelte so much recently I could barely stand having to keep the DOM synced with the data behind the scenes. So I attempted to create a mini framework that would help keep everything up to date. This isn't about the framework I made for myself, this is about creating reactivity in a traditional application without manually keeping track of the data.

I found the easiest way to implement this is to use a borrowing system. Where a function asks for a variable from the object the state is stored in and returns the updated value once complete. After the value is updated rerunning every function that uses the changed variable.

To give context for all the code we will be talking about, here is the shell of the constructor function. We will use this to contain the state of the document and the methods will be used to interact with the state.

1function State(v) {
2  let context = {};
3  let dependencies = {};
4  let renders = {};
5  this.f = async (render = () => {}) => {};
6  this.define = (name, element) => {};
7  this.listen = (name, event, handler) => {};
8}

The context object will be used to store the state of the document, the dependencies object is used to track which render function uses which properties. The renders object will contain the code that is executed when there is a change. The functions f, define and listen are used to interact with the state.

The ffunction or the "function" function is used to define a chunk of code that renders or updates the state or the DOM. To use create a f function you need to break the code used to update the DOM into chunks that are related to each other. Below is an example of a to-do app.

1f(({ list, text, todos }) => {
2  list.innerHTML = "";
3  for (let i = 0; i < todos.length; i++) {
4    let li = document.createElement("li");
5    li.innerHTML = todos[i];
6    list.appendChild(li);
7  }
8  text.value = "";
9});

This function will execute when one of the deconstructed properties is updated. We can check which properties a function uses by using a getter in the main object. To keep things clean in the context object, I chose to create a new object and assign the values of the context object with a getter function attached.

 1let require = {};
 2for (const key in context) {
 3  if (Object.hasOwnProperty.call(context, key)) {
 4    Object.defineProperty(require, key, {
 5      get() {
 6        if (dependencies[key] == undefined) {
 7          dependencies[key] = [];
 8        }
 9        if (dependencies[key].indexOf(name) == -1) {
10          dependencies[key].push(name);
11        }
12        return context[key];
13      },
14    });
15  }
16}

Now we have a copy of the context object with getter functions assigned to every value. The getter function is called when a property from the object is read (in this case via deconstruction). In our function, it checks to see if the name of the rendering function is a dependency of the called property. The dependencies object will allow us to track which rendering functions use what properties of the context object.

The name of the rendering function is created when the function is first declared with the f method. Here is the code used.

1this.f = async (render = () => {}) => {
2  let name = `f${Object.keys(renders).length}`;
3  renders[name] = render;
4  try {
5    await run(render, name, context);
6  } catch (error) {
7    // This doesn't need to do anything
8  }
9};

The three things that happen here are the name is defined as the current index on the renders object prefixed with an f. The render function is stored in the renders object and the function is executed for the first time. You might wonder what the try/catch statement is for. When the code is executed for the first time more than likely all the data you want will not exist and the function will fail. This is fine only for the first run, we are trying to get the dependencies of each function so it doesn't matter if the function runs successfully or not.

I've talked a lot about getting properties from the context object but I haven't talked about setting the properties in the first place. This is done using the define function, the define function is the simplest method we have. It takes two arguments, a name, and a value, then defines them in the context.

1this.define = (name, element) => {
2  context[name] = element;
3};

For a to-do app, our HTML might look something like this, a ul that holds the to-do items. A text input to enter a new item to the list and a button that submits it.

1<h1>To-Do</h1>
2<ul id="todo"></ul>
3<input type="text" id="text" />
4<button id="submit">Enter</button>

To register each element to the context we can use the define method like below.

1let { define, listen, f } = new IXO();
2
3define("list", document.querySelector("#todo"));
4define("text", document.querySelector("#text"));
5define("submit", document.querySelector("#submit"));

This gives us the HTML elements in the context, but to keep the to-do's stored in memory we also need to create an array.

1define("todos", []);

Now we have all of the variables to make the first f function work. To detect user input we can use the listen method that we have not talked about yet. Here is the code behind it.

1this.listen = (name, event, handler) => {
2  context[name].addEventListener(event, async () => {
3    let ctx = context;
4    ctx.parent = context[name];
5    await run(handler, name, ctx, dependencies);
6  });
7};

After the initial rendering of the document events are the main thing that drives a need for rendering of the DOM. For the to-do app, the listen events are defined like this.

1listen("submit", "click", ({ text, todos }) => {
2  todos.push(text.value);
3  return { todos };
4});

Once the submit button is clicked the callback function is called the value of the text input that was defined using the define method and is stored in the todo's array. This causes the f function to be re-run because the callback has updated the todo's array and the f function requires it.

The callback functions of both listen and f are executed by a single function called run. We've already covered some of it when talking about the getter functions but here it is fully.

 1async function run(func, name, context) {
 2    let require = {};
 3    for (const key in context) {
 4        if (Object.hasOwnProperty.call(context, key)) {
 5            Object.defineProperty(require, key, {
 6                get() {
 7                    if (dependencies[key] == undefined) {
 8                        dependencies[key] = [];
 9                    }
10                    if (dependencies[key].indexOf(name) == -1) {
11                        dependencies[key].push(name);
12                    }
13                    return context[key];
14                },
15            });
16        }
17    }
18 /* ... */

The first half of the function is dedicated to creating the getter functions from the context object. The context object is copied directly to the require object which is then passed into the callback.

1/* ... */
2let data = func(require);
3if (typeof data?.then === "function") {
4  data = await data;
5}
6/* ... */

This is where the code is run, func is the callback function passed into the run function. The if statement checks whether the function is a promise, if it is we await the function to complete.

After we have the results of the function, we need to merge the returned data back into the context using Object.assign(). The function running will update the dependencies object so we need to rerun every function that has updated using the code below.

 1 /* ... */
 2    Object.assign(context, data);
 3    for (const key in dependencies) {
 4        if (Object.hasOwnProperty.call(dependencies, key)) {
 5            for (let i = 0; i < dependencies[key].length; i++) {
 6                if (
 7                    renders[dependencies[key][i]] &&
 8                    dependencies[key][i] != name
 9                ) {
10                    console.log(name, key, dependencies[key][i]);
11                    renders[dependencies[key][i]](context);
12                }
13            }
14        }
15    }
16}

While this project is by no means a stable working framework and will never be. I hope this helped shed some light on how variable change tracking can be implemented pretty simply. If you have any suggestions please leave them in the comments below. The full code for this tutorial can be found here.