Frameworkless JavaScript Part 3: One-Way Data Binding

This article is one in a series about writing client-focused JavaScript without the help of libraries and frameworks. It's meant to help developers remember that they can write good code on their own using nothing but native APIs and methods. For more, check out the original article on writing small JavaScript components without frameworks and the previous article on templates and rendering. This article is intended to be a deep-dive into data-binding, how it works, and how you can do it without frameworks like Angular, React, or Ember. It is strongly recommended that you read the previous article before this one.

1-way data-binding

In this article we're looking at 1-way data-binding. That is to say "a method of putting data into the DOM which updates the DOM with new data whenever that data changes". This is the major selling point of the React framework but with a little work you can set up your own data binding in much less code. This is particularly useful when you have an application that sees routine changes to data like a simple game or a stock ticker or a twitter feed; Things that have data that needs to be pushed to the user but no user feedback is required. In this case we need an object with some data in it:

let data_blob = {  
  movie: 'Iron Man',
  quote: 'They say that the best weapon is the one you never have to fire.'
}

A Proxy:

const quote_data = new Proxy(data_blob, {  
  set: (target, property, value) => {
    target[property] = value
    console.log('updated!')
  }  
})

And a poor DOM node to be our guinea pig:

<p class="js-bound-quote">My favorite {{ movie }} quote is "{{ quote }}".</p>  

In this case, we need the data_blob to serve as a storage unit for the proxy. Proxies in ES6 are just a really convenient way to trigger callbacks when certain actions are taken on an object. Here, we're using the proxy to trigger a callback every time somebody changes a value in the data blob. We don't have a way to update the text in the DOM node yet though so let's set that up:

const quote_node = document.querySelector('.js-bound-quote')

quote_node.template = quote_node.innerHTML  
quote_node.render = function render (data) {  
  this.innerHTML = this.template.replace(/\{\{\s?(\w+)\s?\}\}/g, (match, variable) => {
    return data[variable] || ''
  })
}

This gives us a quick and dirty way to update the node's inner HTML with some arbitrary data. The only thing we need to do to connect our new script with the proxy is to substitute the console.log call with quote_node.render(data_blob):

const quote_data = new Proxy(data_blob, {  
  set: (target, property, value) => {
    target[property] = value
    quote_node.render(data_blob)
  }  
})

With all this set up, we can add a quick script to prove that our DOM node is, in fact, updated every time we change the data blob. The exact same way that we want things to happen with a framework but with no external dependencies and WAY less code.

const quotes = [  
  "What is the point of owning a race car if you can't drive it?",
  "Give me a scotch, I'm starving.",
  "I'm a huge fan of the way you lose control and turn into an enourmous green rage monster.",
  "I already told you, I don't want to join your super secret boy band.",
  "You know, it's times like these when I realize what a superhero I am."
]

window.setInterval(() => {  
  const quote_number = Math.floor(Math.random() * quotes.length)
  quote_data.quote = quotes[quote_number]
}, 2000)

This adds a script to change to a random quote every two seconds. Check out the working example below:

See the Pen A simple 1-way data binding in ES6 by jacopotarantino (@jacopotarantino) on CodePen.

This is a little sloppy though. It really only works for one node, one time. Let's clean things up a bit and add constructors for both the nodes and Proxies. Then the functionality is reusable and we can test it a little more easily. A node class might be as simple as:

class BoundNode {  
  constructor (node) {
    this.template = node.innerHTML
    this.node = node
  }

  update (data) {
    let temp_template = this.template.slice(0)
    this.node.innerHTML = temp_template.replace(/\{\{\s?(\w+)\s?\}\}/g, (match, variable) => {
    return data[variable] || ''
    })
  }
}

This creates instances that just have an update method that switches up the node HTML similar to the render method above. Our models are all just Proxy instances but let's create a class for them just to keep the syntax a little more consistent:

class BoundModel {  
  constructor (handlers) {
    const callbacks = []
    const data = {
      add_callback: function add_callback (fn) {
        callbacks.push(fn)
      }
    }

    const proxy = new Proxy(data, {
      set: function (target, property, value) {
        target[property] = value
        callbacks.forEach((callback) => callback())
        return true
      }
    })

    return proxy 
  }
}

There it is. A class that returns a Proxy instance with the callbacks and set trap laid out already. The add_callback method is a bit of sugar to keep the callbacks neatly hidden away and treat our models a little more object-oriented. Make sure to return true in the set handler. This tells the proxy that the assignment succeeded. Things will get a little weird if you don't return a value from that handler. Check out the fully-working example with tests below:

See the Pen A simple 1-way data binding in ES6 with tests by jacopotarantino (@jacopotarantino) on CodePen.