Native Reactive Templating in Javascript - with 15 lines of code

Native Reactive Templating in Javascript - with 15 lines of code

Introduction

The concept of Reactive Templating is in its highest popularity in the Javascript world these days. With frameworks such as React, Angular, VUE (Marko, Solid...), it has become the "de-facto" in front-end programming. In the meantime, modern "vanilla" Javascript keeps being updated with new functionalities, syntax and APIs each new version of the ECMAScript standard. This article will present a way to implement Reactive Templating, using a few lines of plain vanilla Javascript.

What is templating?

Templating is the idea of "reusable components". A "template" represents a "blueprint", a "prototype" for a certain element (in the context we're discussing about - usually a UI element). This template includes several "data placeholders", pieces of expressions within the template body that represents "future data". A template is usually "rendered", using some kind of a "render" mechanism or function, where a data context object is supplied to it; Upon render the placeholders are replaced with values from the given data context object.

For example, a template representing a dynamic paragraph, could look like this:

const template = "<p>Hello. My name is ${name}, and I'm ${age} years old. 
My brother's name is ${brotherName}, and he is ${brotherAge} old.</p>"

The ${...} parts are the "placeholders". A render function possibly renders the template like so:

render(template, {
   name: "Yuval",
   age: 39,
   brotherAge: 45,
   brotherName: "Dror"
});

Template Literals

ES2015 introduced Template Literals to Javascript. These are strings enclosed within 'bactick' marks (`). You can use data placeholders - formatted like in our example above, using ${expression}. The expression can be any valid JS expression, including variables. This, however still does not serve our goal: we'd like to be able to "render" template strings by supplying a "data object" with values to replace the placeholders. The "problem" with Template Literals in this context, is the fact that they are evaluated by the JS parser, and their value is already the interpolated string at run time - we can't "save" the template string in its "prototype" form to a variable, we can only save the interpolated form.

Fortunately, there's a workaround trick, in the form of this line of code:

const createRenderer = templateString => 
   new Function("return `" + templateString +"`;");

This exploits the fact that Javascript has Function constructors, which can be used to create functions dynamically, by passing the function "body" as a string argument. In this case the body of the function is parsed and evaluated when the function is created, so if we define a function that returns a new function (a "High Order Function") created this way, we can actually "save" a noninterpolated template "string". If you console.log the createRenderer const, you will see:

templateString => new Function("return `" + templateString +"`;")

which is a function that can receive a "normal" string (not enclosed within backticks), formatted like a template literal, and return a new function that "renders" the "template-like" string into an interpolated string. This returned function can then be used as our render function. Then, with a little help from context binding, we can have a "real" templating mechanism:

First we save our "template" string as a normal string:

const template = "<p>Hello. My name is ${this.name}, and I'm ${this.age} years old. 
My brother's name is ${this.brotherName}, and he is ${this.brotherAge} old.</p>";

Notice how we added a this context to the variable names, you will soon see why.
The next step is to get our "render" function, by passing the template string to the createRenderer HOF:

const render = createRenderer(template);

Now the fun part: by using the call function, which can call any function with a given this context, we can call the render function, passing it a "data object" as the this, and since the variable placeholders are in the context of this - they will evaluate to the values of the passed data object:

const data = 
{
   name: "Yuval",
   age: 39,
   brotherAge: 45,
   brotherName: "Dror"
});
const renderedString = render.call(data);

console.loging renderedString will show:

<p>Hello. My name is Yuval, and I'm 39 years old. My brother's name is Dror, and he is 45 old.</p>

We can even use getter functions in the data object, for example by replacing brotherAge to:

get brotherAge() { return this.age + 6; },

Reactiveness

The templating mechanism we explored so far is "one-way binded" - we call a render function giving it some data and get back a rendered result. After that any changes to the values of the data variables, will not be reflected in the rendered string.

"Reactiveness" introduces "two-way" data binding: if a value of a data variable is changed - it is immediately reflected in the rendered result as well. To achieve this functionality in our little "templating engine", we will make use of a Javascript mechanism known as ** Proxies ** - this enables "overriding" the functionality of native JS operators (such as the new operator, or the assignment (=) operator) for given objects. And so, we can "tap in" the assignment functionality for our data object, and add a "rerender" each time its value changes. We will also use an element as a "container" for our rendered template, and so we can now write a "mount" function; this function will receive 3 arguments: the "container" DOM element, the template string, and a data object. The function will return the "proxied" version of the data object (which we can call "state"):

function mount(container, markup, data) {
    // createRenderer was defined in the first part
    const render = createRenderer(markup);
    container.innerHTML = render.call(data);
    return new Proxy(data, {
      // This function will run any time a value of data changes
      set(obj, prop, value) {
        Reflect.set(...arguments);
        container.innerHTML = render.call(data);
        return true;
      }
    });
  }

Let's set our template:

const template = "<p>Hello. My name is ${this.name}, and I'm ${this.age} years old. 
My brother's name is ${this.brotherName}, and he is ${this.brotherAge} old.</p>";

Create our DOM container:

var container = document.createElement('div');
document.body.appendChild(container);

Define our data context:

const data = 
{
   name: "Yuval",
   age: 39,
   brotherAge: 45,
   brotherName: "Dror"
};

Now we are ready to mount:

var state = mount(container, template, data);

The rendered <p> element will appear in the body of the document:

<p>Hello. My name is Yuval, and I'm 28 years old. 
My brother's name is Dror, and he is 45 old</p>

Now, the cool part: state is the "proxied" data object, we gave it the power of reactivity. If you change any of its properties - the change will immediately be shown in the rendered string (the template render function will be called again). So, if we add the line:

setTimeout(()=> { state.age = 28; }, 4000);

As the last line, first we will see the paragraph rendered with 'I'm 39 years old', and after 4 seconds, you will see it change to '28 years old'.

Here is the full code block:

const createRenderer = 
templateString => new Function("return " + templateString +";");

function mount(container, markup, data) { 
    const render = createRenderer(markup); 
    container.innerHTML = render.call(data); 
    return new Proxy(data, { 
        // This function will run any time a value of data changes 
        set(obj, prop, value) { 
            Reflect.set(...arguments); 
            container.innerHTML = render.call(data); 
            return true;
        } 
    }); 
}

const template = "Hello. My name is ${this.name}, and I'm ${this.age} 
    years old. My brother's name is ${this.brotherName}, 
    and he is ${this.brotherAge} old."; 
const container = document.createElement('div'); 
document.body.appendChild(container); 
const data = { 
   name: "Yuval", 
   age: 39, 
   brotherAge: 45, 
   brotherName: "Dror" 
}; 
var state = mount(container, template, data); 
setTimeout(()=> { state.age = 28; }, 4000);

Reactive “mapping”

Now let's do something even cooler. Suppose we have a "feed" of "posts", where each item shows the post's "author", the post creation date, and the post content. Since Template Literals allows using JS expressions in the placeholders, we can even use functions there. So our "feed" template, can look like this:

${this.posts.map(post=> 
"By: " + post.name + "Date: " + post.date + "" + post.content + "")}

Then we can call our mount function with a data object that looks like this:

{
   posts: [ 
      { 
         name: "Yuval", 
         date: "07-01-2022", 
         content: "This is post number 1" 
      }, 
      { 
         name: "Yossi", 
         date: "07-02-2022", 
         content: "This is post number 2"
      }, 
      { 
         name: "Shula", 
         date: "08-01-2022", 
         content: "This is post number 3" 
      }
    ] 
}

After rendering, we could, for example, add another "post" to our feed:

var posts = [...state.posts]; 
posts.push({ 
    name:"Moran", 
    date:"08-03-2024", 
    content: "This is post number 4"
}); 
state.posts = posts;

and the container will rerender with the 4th post!.

Summary

This is just the tip of the iceberg, and of-course, large-scale frameworks implement much more than this. Nonetheless, it shows the great power of two JS mechanisms: Template literals, and Proxies.

Proxies are a main ingredient in my reactive ODM framework for MongoDB: DeriveJS, and is also, in-fact, the basis of reactiveness in version 3 of VUEJS.