In this story we will be clearing the common curiosity around how two-way data binding works. Most of you might be familiar with two-way data via binding via knockout.js, angular, vue or react.
First let’s take a look at the the final application so that you will have an idea of what we are building.
As usual we will break the protocol and implement our own version of two way data binding using JavaScript proxy.
First let’s take a look at the HTML that we will be working with.
<div id="root">
<h1>Vanilla JS - Two way binding using Proxy</h1>
<div class="form-group">
<div class="form-elements">
Name: <input data-bind="username" type="text" />
<br/>
<span data-bind="username"></span>
<br/><br/>
Email: <input data-bind="email" type="text"/>
<br/>
<span data-bind="email"></span>
</div>
<div class="button-group">
<button onclick="log()">Inspect Scope</button>
<button onclick="changeUsernameByCode()">Change Username Scope</button>
<button onclick="changeEmailByCode()">Change Email Scope</button>
</div>
</div>
<div id="debug-container">
<button id="btnClearLog" title="Clear logs" onclick="clear_logs()">x</button>
<pre id="debug">
</pre>
</div>
</div>
The astute reader might have observed the “data-bind” attribute. You can name it anything, but I prefer the the name “data-bind”. Angular.js uses ng-bind, Angular uses banana in a box syntax.([])
Here we have bounded the text input with the span as shown below.
Name: <input data-bind="username" type="text" />
<br/>
<span data-bind="username"></span>
The above code indicates when the username input changes the <span> element should also reflect the value. This is one-way binding.
Behind the scene in code, we will create a scope object which will store the “username” and other data-bind properties.
The idea behind two-way binding is when the UI changes the data model in this case , scope, should reflect it. And when scope/data changes the UI should reflect it.
We have also created couple of button elements to inspect scope, change the scope values.
<div class="button-group">
<button onclick="log()">Inspect Scope</button>
<button onclick="changeUsernameByCode()">Change Username Scope</button>
<button onclick="changeEmailByCode()">Change Email Scope</button>
</div>
So, here when the user clicks “Change username Scope” button , the code updates the scope with key “username” and similarly for email as well.
But when the scope changes the textbox and the respective span should also reflect the changed value.
The “Inspect Scope” button logs the scope information to the log window.
Let’s get to the JavaScript code now.
The first thing we will create the proxy handlers for managing scope {} object.
What is a Proxy?
The Proxy object is used to define custom behavior for fundamental operations (e.g., property lookup, assignment, enumeration, function invocation, etc.).
To create a proxy we use the below syntax
Syntax
const p = new Proxy(target, handler)
Parameters
target
A target object to wrap with Proxy
. It can be any sort of object, including a native array, a function, or even another proxy.
handler
An object whose properties are functions that define the behavior of proxy p
when an operation is performed on it.
In our case the target will be the scope {] object variable that we will be creating and the handler code is displayed below.
Role of Handler
The handler called will be invoked when the object under consideration is being updated/set or read/get.
The ‘get’ method will be called when reading the value and the ‘set’ method will be called when updating the value.
Let’s first define our “handler” first.
The complete “handler” code is displayed below. First let’s write the ‘get’ method of the handler. The get method is quite simple. It will simple return the value back.
But first let’s grab all the elements with the ‘data-bind‘ attribute. We need these elements to be updated when scope changes.
let elms = document.querySelectorAll("[data-bind]");
Lets code the “get” trap for the proxy.
const handler = {
get: function(obj, prop) {
return obj[prop] ;
},
The set method is a bit involved one but quite simple once we understand the basic mechanism.
The set trap for proxy
Steps in the set method for our use case.
- Set the new value to the object with the prop as the key
- Loop through all data-bind elements and update the element with the new value
const handler = {
get: function(obj, prop) {
return obj[prop] ;
},
set: function(obj, prop, value) {
obj[prop] = value;
elms.forEach((elm) => {
if (elm.getAttribute("data-bind") == prop) {
// Only supporting text and textarea
// => Feel free to add support for more
if (elm.type && (elm.type === "text" || elm.type === "textarea")) {
elm.value = value;
} else if (!elm.type) {
elm.innerHTML = value;
}
}
})
return true; // indicates success
}
};
Let’s continue with the rest of the code. (I have organized for easy reference and showing the important parts only)
Setting up scope pass through proxy
Let’s do it step by step.
- Grab the elements with the data-bind attributes
- Create a new Proxy using the scope and the handler
- Loop through all data-bind elements and attach a keyup event
- When input with data-bind property change update the scope with the respective propToBind key
- This will fire the proxy’s set trap (which will update all the related bounded elements)
let elms = document.querySelectorAll("[data-bind]");
let scope = {}; // stores data
scope = new Proxy(scope, handler);
elms.forEach((elm) => {
if (elm.type === "text" || elm.type === "textarea") {
let propToBind = elm.getAttribute("data-bind");
elm.addEventListener("keyup", (e) => {
scope[propToBind] = elm.value; // proxy set method fires
});
}
});
Let’s also take a look at the rest of the button click handler code for setting scope values and inspecting logs.
Rest of the methods (comments have explanation)
// Outputs the JSON structuree of scope
const log = function () {
Object.keys(scope).forEach((k) => {
debug.innerHTML += JSON.stringify(scope) + "<br/>";
});
debug.scrollTop = debug.scrollHeight;
}
// Change the username scope on click of the button
const changeUsernameByCode = function () {
scope.username = "username Changed by Code";
}
// Change the email scope on click of the button
const changeEmailByCode = function () {
scope.email = "email changed by Code";
}
Please note this was a quick proof of concept and the original article using property getter/setter can be read here.
https://medium.com/better-programming/js-vanilla-two-way-binding-5a29bc86c787