2-way binding using Vanilla Javascript

In this article, I implement "Two-way binding" using vanilla Javascript. Ok, but what's a two-way binding you ask?

What's a Two-Way binding?

To understand this, let's start by understanding what a data-binding actually is.

Data Binding

Data Binding is a general concept/technique that establishes a connection between the UI and the data source. Specifically, in javascript terms, it connects the UI components (input boxes, fields, ...) and the associated data model (ng-model instance in case of Angular, ...). This essentially means that if one changes, the other would automatically get updated.

One-way data binding

As for the name, although it's connected, the updates only flow one way. Eg.) In React application, whenever the parent component changes the props, the children are updated based on the props value and not the other way around (We use callbacks to inform the parent about children updates in React).

const ParentComponent = () => {
    const [counter, setCounter] = useState<number>(0);
    return <ChildComponent counter={counter} />;
}

// {counter} gets updated whenever the value gets changed in the parent component.
const ChildComponent = ({ counter }: { counter: number }) => {
    return <span>{counter}</span>
}

Two-way data binding

Two-way data binding on the other hand has updates possible both ways. Eg.) Angular components are bound to a model and the view. Whenever any of the models or the view changes, the associated other would get updated.

javascript - What is two way binding? - Stack Overflow

// src/app/sizer.component.ts
export class SizerComponent {

  @Input()  size!: number | string;
  @Output() sizeChange = new EventEmitter<number>();

  inc() { this.resize(+1); }

  resize(delta: number) {
    this.size = Math.min(40, Math.max(8, +this.size + delta));
    this.sizeChange.emit(this.size);
  }
}
<!-- src/app/sizer.component.html -->
<div>
  <button type="button" (click)="inc()" title="bigger">+</button>
  <span [style.font-size.px]="size">FontSize: {{size}}px</span>
</div>
<!-- src/app/app.component.html -->
<app-sizer [(size)]="fontSizePx"></app-sizer>
<div [style.font-size.px]="fontSizePx">Resizable Text</div>

Two-Way data binding using Vanilla JS

Now that we understood what a two-way binding is, let's try to implement a small use case with vanilla javascript. Let's have an input text field and a span tag. Whenever input text changes, the changed value will be automatically reflected in the span tag associated with that input model.

Angular uses [] for representing the model and {{}} for displaying the value. Let's modify that a bit and use twoWayModel="..." and twoWayDisplay to represent models and displays respectively.

Our desired output needs to be something like the below:

<!-- our desired output -->
<input type="text" twoWayModel="name" value="" /> <!-- Model is name -->
<span twoWayDisplay="name"></span> <!-- displaying the name model value -->

Let's target this in 2 steps:

  1. Implementing One-way data binding

  2. Implementing the other way of data binding.

Implementation

Let's have the index.html file created with basic bits required. Something like the below:

<!DOCTYPE html>
<html>
  <head>
    <title>Two Way Databinding using Vanilla JS</title>
    <meta charset="UTF-8" />
  </head>

  <body>
    <!-- Model is name -->
    <input type="text" twoWayModel="name" value="" />
    <!-- displaying the name model value -->
    <span twoWayDisplay="name"></span>
    <script src="src/index.js"></script>
  </body>
</html>

We have input and span tags and that's all we need from index.html for now.

For the Javascript, let's create a class TwoWayBinding and assign some defaults like the below:

class TwoWayDataBinding {
  constructor(
    modelIdentifier = "twoWayModel",
    displayIdentifier = "twoWayDisplay"
  ) {
    this.modelIdentifier = modelIdentifier;
    this.displayIdentifier = displayIdentifier;
  }
}

We could as well avoid the default but it is nicer to have in case we want to have different identifiers in the future, we can just pass them down as arguments to the constructor method.

Now, let's identify all the models present in our code so that we can listen to their changes later on.

grabAllModels() {
    const models = document.querySelectorAll(`[${this.modelIdentifier}]`);
    console.log(models); // displays the NodeList of dom elements and currently only one input element.
  }

Let's add event listeners to the model elements:

grabAllModels() {
    const models = document.querySelectorAll(`[${this.modelIdentifier}]`);
    models.forEach((model) => {
      model.addEventListener("keyup", (event) => {
        console.log(event.target.value);
      });
    });
  }

If you notice, we are iterating and adding event listeners to each model. But here keyup is kinda hard-coded. While this works for our example, it may not work let's say we have the model attached to a select/radio/checkbox. We could set some config for this to work for most of the other ones like the below:

EVENT_MAP = {
  input: {
    text: "keyup",
    email: "keyup",
    password: "keyup",
    radio: "onchange",
    checkbox: "onchange"
  },
  textarea: "keyup",
  select: "onchange"
};

We end up with this modified version which supports inputs, select, textarea, and so on ... This can even be modified to support custom tags.

grabAllModels() {
    const modelElements = document.querySelectorAll(
      `[${this.modelIdentifier}]`
    );
    modelElements.forEach((modelElement) => {
      const model = modelElement.getAttribute(this.modelIdentifier);
      const tagName = modelElement.tagName.toLowerCase();
      let listenerType = this.EVENT_MAP[tagName];

      // In case of input, go one step further to check its type.
      if (tagName === "input") {
        const attr = modelElement.getAttribute("type");
        if (attr !== "button") {
          listenerType = this.EVENT_MAP[attr];
        }
      }
      this.attachListener(
        modelElement,
        listenerType,
        this.handleEvent.bind(this, model)
      );
    });
  }

  handleEvent(model, event) {
    console.log(model, event); // Prints out nice model name and the event object.
  }

  attachListener(element, listenerType, callback) {
    element.addEventListener(listenerType, callback);
  }

As we type, we are getting the proper values in the console. Great! Now we have to automatically show them in the span tag or whatever tag uses the display model attribute.

DISPLAY_MAP = {
  input: "value" // For simplicity, considering only text/email/password type inputs
};

handleEvent(model, event) {
  this.updateView(model, event.target.value);
}

updateView(model, value) {
  // Fetch all elements which are listening to this model.
  const views = document.querySelectorAll(
    `[${this.displayIdentifier}=${model}]`
  );
  views.forEach((view) => {
    const tagName = view.tagName.toLowerCase();
    const keyToDisplay = this.DISPLAY_MAP[tagName] || "innerHTML"; // Other than inputs default to `innerHTML` for others like div,p,span,...
    view[keyToDisplay] = value;
  });
}

For simplicity of this article, I am considering only Input display elements like [text, email, and password, ... and not radio, checkbox, ... for handling the displays] it is just a matter of updating the config and handling specifically for them. Also, anything other than input, I am defaulting to innerHTML which works for most cases. This displays the changes in values in the span tag. You can take a look at it to live in the code-sandbox link:

This completes the first part of the task which is the one-way data binding. If you notice properly, this automatic updates only happen when it is changed by the user in the text box, and let's say if it is changed from elsewhere without the user's interaction, then the span tag will not get updated. This brings us to the second flow.

Let's now implement the other flow. For that, we would need to have a central data source that triggers the notification accordingly as and when the model gets changed from elsewhere.

In order for us to implement this, we need to first understand a nice little method called Object.defineProperty

It takes the following structure:

Object.defineProperty(obj, prop, descriptor)

MDN Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty

const data = {};
Object.defineProperty(data, 'prop', {
    get: function () {
        console.log('Getter called');
    },
    set: function (value) {
        console.log('Setter called');
    }
});

Whenever we try to read from/write to the data object, we would get the respective console statements. This essentially means, we have control over when a model is modified and viewed.

Applying this to our implementation, let's modify some of our bits:

constructor(
    modelIdentifier = "twoWayModel",
    displayIdentifier = "twoWayDisplay"
  ) {
    this.modelIdentifier = modelIdentifier;
    this.displayIdentifier = displayIdentifier;
    this.dataSource = new DataSource(); // Data source
    this.dataSource.onUpdate(this.updateView.bind(this));
    this.grabAllModels();
  }

We have now added a DataSource class to which we would delegate the responsibilities of setting and getting the data. Also, we will subscribe to it so that whenever an update occurs, it would notify us (Similar to pub-sub). Let's implement the DataSource class for our use case.

class DataSource {
  DATA_SOURCE = {};
  callback = null;
  addModelToSource(model) {
    if (!this.DATA_SOURCE.hasOwnProperty(model)) {
      let value = "";
      Object.defineProperty(this.DATA_SOURCE, model, {
        set: (newValue) => {
          value = newValue;
          this._onUpdateHelper(model, value);
        },
        get: () => {
          return value;
        },
        enumerable: true
      });
    }
  }
  update(model, value) {
    this.DATA_SOURCE[model] = value;
  }
  _onUpdateHelper(model, value) {
    this.callback(model, value);
  }
  onUpdate(callback) {
    this.callback = callback;
  }
}

This has methods to update the data, callback in case of any updates externally, and so on...

grabAllModels() {
    const modelElements = document.querySelectorAll(
      `[${this.modelIdentifier}]`
    );
    modelElements.forEach((modelElement) => {
      const model = modelElement.getAttribute(this.modelIdentifier);
      const tagName = modelElement.tagName.toLowerCase();
      let listenerType = this.EVENT_MAP[tagName];

      // In case of input, go one step further to check its type.
      if (tagName === "input") {
        const attr = modelElement.getAttribute("type");
        if (attr !== "button") {
          listenerType = this.EVENT_MAP[attr];
        }
      }
      this.dataSource.addModelToSource(model); // Adding to the data source.
      this.attachListener(
        modelElement,
        listenerType,
        this.handleEvent.bind(this, model)
      );
    });
  }

We just include a line this.dataSource.addModelToSource(model); in our existing method which adds the model instantiation. Basically, it creates a field for this model.

If the DATA_SOURCE = {}, running this for model ("name"), it becomes DATA_SOURCE = { name: "" }

handleEvent(model, event) {
    // this.updateView(model, event.target.value);
    // Rather than updating the view directly, we would update the data source,
    // which would trigger the view updates automatically.
    this.dataSource.update(model, event.target.value);
  }

Rather than directly triggering the view updates every time anything changes in the model, we delegate the responsibility to the data source. The DataSource would then trigger the updates and the view gets modified accordingly.

You can take a look at this code sandbox link for the full code - https://codesandbox.io/s/two-way-data-binding-t3g07o?file=/src/index.js

This implements our "Two-way" Data binding using vanilla javascript.

Cheers,

Arunkumar Sri Sailapathi.

0
Subscribe to my newsletter

Read articles from ArunKumar Sri Sailapathi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

ArunKumar Sri Sailapathi
ArunKumar Sri Sailapathi

I am a passionate front-end engineer and an aspiring entrepreneur. I like coding and building products from the scratch. I plan to write about some of the interesting things that I came across during my 10+ years of professional career.