Another Two-Way Data Binding Article

Published Dec 06, 2017Last updated Dec 13, 2017
Another Two-Way Data Binding Article

I recently read Robert Yarborough's article (How and why I built 2-Way Data Binding Library) and while his library was super lightweight, easy to implement, and would certainly work for many simple use cases I thought it was lacking in some areas. So with this in mind, I set about creating something a bit more robust and scalable.

Skip to my approach
View the completed codepen

Concerns with the previous approach

EventListener explosion

In his article, Robert takes a very direct approach to syncronizing input data by adding an onKeyup function to every element that has a specific CSS class that dictates the data in scope that it binds to. While this would work for small applications, as the number of elements syncing to data increases you would see more sluggish performance. Here is a jsPerf illustrating how the browser handles binding many event listeners to many elements.

keyup Event

I think because utilizing change or input would create an infinite loop when an input element was updated, Robert used keyup to trigger $scope udpates. While this avoided the obvious pitfall of breaking the entire application, it lacks some common functionality that I believe many users would expect such as pasting using the right click menu. Because I was already resolved to not create the an EventListener for each element, this was also an issue that would be easily sidestepped.

Empty Initialization

I also found that due to the keyup event and object setter being the only triggers to syncronize the data, if you prepopulate the value of an input element it will not update until a key is pressed in that input. And if you type into one of the other inputs that shares that data source, or you change the $scope value through javascript, you will overwrite the initial value entirely.

Predefined data keys

Although defining the keys of the data you plan to syncronize is all well and good, I concluded it required a bit too much boilerplate for my liking. Knowing my own habits of forgetting to update a string somewhere and spending way too long trying to figure out why my code doesn't work, only to have a really disappointing "Ah hah" moment in the end, I decided this was a pattern that would eventually drive me insanse and added dynamic data keys as a requirement for my version as well.

CSS Class based binding

As mentioned above a CSS class based selector was used as a means of identifying which elements should be syncronized to the $scope. This is a small but important oversight. Utilizing CSS classes in this way leaves you exposed to potential style inheritance issues. If you accidentally use the class to style an element it could affect the entire application. Or if you style that class intentionally and later decide to apply the data to a different element that you don't want to inherit those styles, you'd be asking for serious regression issues going and adding a new class to all of your existing data bound elements. Anyway, this was a simple thing to resolve, so I simply took a different approach.

My Approach

Finally, the steak and potatoes! Given the issues I listed above, I ended up with this list of features as requirements:

  • ES2015+ (Just because it's almost 2018)
  • No CSS class based binding
  • Limit number of EventListeners
  • Handle all forms of data changing, not just from keyup or setting through code
  • Dynamic data keys
  • Automatic synchronization with default values

No CSS class based binding

Since this is the root of quite a few of the other issues, and pretty much the determines how the rest of the library functions, let's start here. Instead of selecting the elements to bind to from their CSS class, we're going to use a data attribute called data-scope. If you're unfamiliar with HTML data attributes I suggest reading up on them, since they have a whole featureset that make them very easy to work with in javascript. Ok, to our index.html file is going to get some input elements.

...
<body>
  <label>Name: </label>
    <input class="input-1" data-scope="name" type="text" />
    <input class="input-2" data-scope="name" type="text" />
    <hr />
    <label>Age: </label>
    <input class="input-3" data-scope="age" type="number" />
    <input class="input-4" data-scope="age" type="number" />
</body>

Let's go ahead and style all of these inputs differenly too, just to demonstrate the flexibility of moving away from the CSS class based binding.

.input-1 {
  background-color: #f00
}
.input-2 {
  color: #fff;
  background-color: #00f
}
.input-3 {
  background-color: #0f0
}
.input-4 {
  background-color: #ff0
}

Now we should have something that looks like this.Styled-Inputs.png

JS Disclaimer

I should take a quick second to mention that the javascript in this project will be using relatively new features and may not work in older browsers, but I'm pretty sure IE11+ is a safe bet out of the box, provided that you're transpiling with babel. If you're unfamiliar with babel or ES2015+ in general, unfortunately that's out of the scope of this post, but feel free to leave a comment and maybe I'll double back on this project and rewrite it in ES5.

Limit number of EventListeners

Now that we have some inputs to orchestrate, let's start writing some code to handle the data binding. My plan for limiting the EventListener count is to keep track of what element has focus on the page, and only have event listeners on that element. Then, when the element loses focus, each EventListener should be removed. To do this we need to create a window listener on the focus event that will capture all focus events on the page.

let target;
let $scope = {};

document.addEventListener('DOMContentLoaded', (event) => {
  window.addEventListener('focus', handleFocus, true);
});

function handleFocus(evt) {
  if (target) {
    if (target === evt.target) {
      // Don't reset the listeners if the event target is the current target
      return;
    }
    target.removeEventListener('input', handleUpdate);
  }
  target = evt.target;
  target.addEventListener('input', handleUpdate)
}

function handleUpdate(evt) {
  // We're going to handle updating $scope here.
}

In the above code, every time a new element gains focus on the page, any previous target element has it's listener removed then the event target becomes the target and an input listener is added.

Handle all forms of data changing, not just from keyup or setting through code

It was at this point that I decided it best to start to identify the different structures I would be handling in the application and create some classes to help organize the logic. I came to the conclusion that I would require three classes:

  • Scope: The main class that contains all the data and provides a proxy for all of the ScopeItem instances
  • ScopeItem: This class would handle each individual data value and keep track of the elements that need to be updated when it changes.
  • Target: A class wrapper for the focus management we did above.

Though Scope will be the main class that we ultimataly expose to whatever application is using this library, let's start with the ScopeItem since Scope is a glofified ScopeItem factory.

Scope Item

export default class ScopeItem {
  constructor(value = null) {
    // Creates an empty array of observers and sets _value
    this.observers = [];
    this._value = value;

    return this;
  }
    
  // Getter to return the private `_value` property.
  get value() {
    return this._value;
  }
    
  // Setter that calls updateObservers
  set value(value) {
    this.updateObservers(value);
    this._value = value;
  }

  // Iterates over all observers and calls syncContent for each
  updateObservers = (value) => {
    this.observers.forEach(this.syncContent(value));
  }

  // Synchronizes value from ScopeItem to node
  syncContent = (value) => {
    return (elem) => {
      if (elem.nodeName === 'INPUT') {
        const type = elem.getAttribute('type');
        if (type === 'checkbox' || type === 'radio') {
          return elem.checked = value;
        }
        return elem.value = value;
      }
      return elem.innerHTML = value;
    	};
  };

  // Inserts a new observer element and syncs it's content
  addObserver = (elem) => {
    this.syncContent(this._value)(elem);
    this.observers.push(elem);
  }

  // Removes an observer element
  removeObserver = (elem) => {
    const index = this.observers.indexOf(elem);
    if (index > -1) {
      this.observers.pop(index);
    }
  }
}

Let's disect this method by method, shall we.

constructor(value = null) {
  // Creates an empty array of observers and sets _value
  this.observers = [];
  this._value = value;
   
  return this;
}

The constructor for ScopeItem begins by creating an empty array for the observers propery. This will be used to keep track of the elements that need to be updated when the value is updated. Next it takes the value argument, which defaults to null if not supplied, and assigns it to the private _value property.

// Getter to return the private `_value` property.
get value() {
  return this._value;
}
   
// Setter that calls updateObservers
set value(value) {
  this.updateObservers(value);
  this._value = value;
}

The getter for value simply returns the _value property. The setter, on the other hand, calls the updateObservers method with the new value before updating the _value property.

// Iterates over all observers and calls syncContent for each
updateObservers = (value) => {
  this.observers.forEach(this.syncContent(value));
}

// Synchronizes value from ScopeItem to node
syncContent = (value) => {
  return (elem) => {
    if (elem.nodeName === 'INPUT') {
      const type = elem.getAttribute('type');
      if (type === 'checkbox' || type === 'radio') {
        return elem.checked = value;
      }
      return elem.value = value;
    }
    return elem.innerHTML = value;
  };
}

Finally we end up at the updateObservers method that iterates over each observer and performs the syncContent method which updates either their value, checked or innerHTML property depending on their nodeName.

So with our ScopeItem class complete we would now update every registered obsverer element any time a ScopeItem instance is updated. Let's now manage getting those observers added in the first place.

Dynamic data keys

Handling the creation of dynamic data keys is actually a lot simpler than it sounds. The Proxy object allows the creation of catchall setter and getter methods for any property of the object being proxied. This allows us to create a new ScopeItem every time an element has a new value for data-scope.

This could also be accomplised using Object.defineProperty every time an undefined key is encountered, but Proxy is more terse, so we'll be using that.

Scope

import ScopeItem from "./ScopeItem";

// Wrapper class for creating the Scope Proxy
export default class Scope {
  constructor() {
    // Creates proxy
    this.$scope = {};
    this.proxy = this.createProxy();

    // Get the list of elements that utilize `data-scope`
    const scopedInputs = document.querySelectorAll("[data-scope]");

    // Subscribe all `scopedInputs` to the scope instance
    scopedInputs.forEach(this.subscribeToScope);

    // Return the proxy for easy access to values in code
    return this.proxy;
  }

    // Static function to normalize element values
  static parseElementValue = (elem) => {
    if (elem.nodeName === "INPUT") {
      const type = elem.getAttribute("type");
      if (type === "checkbox" || type === "radio") {
        return elem.checked;
      }
      return elem.value;
    }
    return elem.innerHTML;
  };

  // Creates a Proxy that instantiates a new ScopeItem for every key accessed and only allows the value to be updated
  createProxy = () => {
    return new Proxy(this.$scope, {
      get(target, key) {
        if (!target[key]) {
          target[key] = new ScopeItem();
        }

        return target[key].value;
      },
      set(target, key, value) {
        if (!target[key]) {
          target[key] = new ScopeItem();
        }

        target[key].value = value;

        return true;
      }
    });
  };
    
  // Adds element to scope observer array to be updated on changes
  subscribeToScope = (elem) => {
    const scopeKey = elem.dataset.scope;

    if (!this.$scope[scopeKey]) {
      this.$scope[scopeKey] = new ScopeItem(Scope.parseElementValue(elem));
    }

    this.$scope[scopeKey].addObserver(elem);
  };
}

And again we'll break this down method by method.

constructor() {
  // Creates proxy
  this.$scope = {};
  this.proxy = this.createProxy();

  // Get the list of elements that utilize `data-scope`
  const scopedInputs = document.querySelectorAll("[data-scope]");

  // Subscribe all `scopedInputs` to the scope instance
  scopedInputs.forEach(this.subscribeToScope);

  // Return the proxy for easy access to values in code
  return this.proxy;
}

The constructor does quite a bit in the Scope class. It begins by creating an empty object as the $scope property and also populates the proxy property using the createProxy method.

Next, it stores all elements from the dom that contain a data-scope attribute in a scopedInputs constant that is then iterated over, performing the subscribeToScope method on each element.

Finally, the proxy property is actually returned as this is a more convenient way for external code to interface with the underlying ScopeItem instances.

// Static function to normalize element values
static parseElementValue = (elem) => {
  if (elem.nodeName === "INPUT") {
    const type = elem.getAttribute("type");
    if (type === "checkbox" || type === "radio") {
      return elem.checked;
    }
    return elem.value;
  }
  return elem.innerHTML;
};

The next method is a static method called parseElementValue. This is necessary in order to properly set the ScopeItem values based on the type of elements the value is being set from.

// Creates a Proxy that instantiates a new ScopeItem for every key accessed and only allows the value to be updated
createProxy = () => {
  return new Proxy(this.$scope, {
    get(target, key) {
      if (!target[key]) {
        target[key] = new ScopeItem();
      }

      return target[key].value;
    },
    set(target, key, value) {
      if (!target[key]) {
        target[key] = new ScopeItem();
      }

      target[key].value = value;

      return true;
    }
  });
};

The createProxy method does as stated at the beginning of this section. It creates dynamic getters and setters for all properties of the $scope property. This allows us to automatically create a ScopeItem for any key that is accessed either by reading or writing.

Further it allows us to hide the underlying ScopeItem properties that are of no interest to someone utilizing the library. Accessing any key in the Scope instance will return only the value property from the corresponding ScopeItem instance.

// Adds element to scope observer array to be updated on changes
subscribeToScope = (elem) => {
  const scopeKey = elem.dataset.scope;

  if (!this.$scope[scopeKey]) {
    this.$scope[scopeKey] = new ScopeItem(Scope.parseElementValue(elem));
  }

  this.$scope[scopeKey].addObserver(elem);
};

Utilized in the constructor the subscribeToScope method takes an element containing a data-scope attribute and adds it to the corresponding ScopeItem observers property via the addObserver method.

This is also the only method that directly accesses the $scope property as it needs access to the underlying ScopeItem addObserver method.

Inside of this method another one of our requirements (Automatic synchronization with default values) is met as well. The passing of the element value to a new ScopeItem if $scope doesn't contain the expected key will in turn set that value for any following elements that are added as observers to that ScopeItem.

Target (revisited)

Now everything has come full circle as we create the Target class to handle all the EventListener management. Because the Target class will require the ability to update the State, we need to pass a reference, as well as create an instance inside the State class. So let's modify our imports and constructor in State

import ScopeItem from "./ScopeItem";
import Target from "./Target"; // Add this line

// Wrapper class for creating the Scope Proxy
export default class Scope {
  constructor() {
    // Creates proxy and Target reference
    this.$scope = {};
    this.proxy = this.createProxy();
    this.target = new Target(this.proxy); // Add this line
        ...
  }
...
}

And here is the full Target class.

import Scope from "./Scope";

// Class for managing target element and minimizing active eventListeners;
export default class Target {
  // Takes a scope instance as an argument to handle updates
  constructor($scope) {
    this.$scope = $scope;

    // Event listener object to remove listeners later in the lifecycle
    this.eventListeners = {
      input: this.handleUpdate,
      change: this.handleUpdate
    };
    // Sets the initial element to the active document element
    this.element = document.activeElement;

    // Listens for all focus events on the window object
    window.addEventListener("focus", this.handleFocus, true);
  }

  // Getter to return the private `_element` property.
  get element() {
    this._element;
  }
  // Setter that performs event listener cleanup and setup whenever element changes
  set element(element) {
    this.removeEventListeners(this._element);
    this.addEventListeners(element);

    return this._element = element;
  }

  // Removes event listeners from element that is losing focus
  removeEventListeners = (element) => {
    if (!element) {
      return;
    }

    for (let key in this.eventListeners) {
      element.removeEventListener(key, this.eventListeners[key]);
    }
  };

  // Adds event listeners from element that is gaining focus
  addEventListeners = (element) => {
    if (!element) {
      return;
    }

    for (let key in this.eventListeners) {
      element.addEventListener(key, this.eventListeners[key]);
    }
  };

  // Updates the value of the ScopeItem that the elements `data-scope` attribute signifies on change
  handleUpdate = (evt) => {
    const scopeKey = evt.target.dataset ? evt.target.dataset.scope : null;
    if (scopeKey) {
      this.$scope[scopeKey] = Scope.parseElementValue(evt.target);
    }
  };

  // Updates the value of the ScopeItem that the elements `data-scope` attribute signifies on change
  handleFocus = (evt) => {
    this.element = evt.target;
  };
}

And the final breakdown

constructor($scope) {
  this.$scope = $scope;

  // Event listener object to remove listeners later in the lifecycle
  this.eventListeners = {
    input: this.handleUpdate,
    change: this.handleUpdate
  };
  // Sets the initial element to the active document element
  this.element = document.activeElement;

  // Listens for all focus events on the window object
  window.addEventListener("focus", this.handleFocus, true);
}

This should all look vaguely familiar from before. The constructor first assigns it's own $state property to the state passed as an argument. Then it defines the eventListeners property which is handy later on for adding and removing EventListeners easily. The element property is set to the current activeElement of the document. And finally the focus EventListener is added to the window just as before.

// Getter to return the private `_element` property.
get element() {
  this._element;
}
// Setter that performs event listener cleanup and setup whenever element changes
set element(element) {
  this.removeEventListeners(this._element);
  this.addEventListeners(element);

  return this._element = element;
}

The element getter is another simple wrapper for the _element property, while it's setter handles removing and adding event listeners as the element peoperty is changed.

// Removes event listeners from element that is losing focus
removeEventListeners = (element) => {
  if (!element) {
    return;
  }

  for (let key in this.eventListeners) {
    element.removeEventListener(key, this.eventListeners[key]);
  }
};

// Adds event listeners from element that is gaining focus
addEventListeners = (element) => {
  if (!element) {
    return;
  }

  for (let key in this.eventListeners) {
    element.addEventListener(key, this.eventListeners[key]);
  }
};

Both the addEventListeners and removeEventListeners methods perform more or less the same operation. They loop over the keys in the eventListeners property, and either add or remove the corresponding EventListener from the element argument.

// Updates the value of the ScopeItem that the elements `data-scope` attribute signifies on change
handleUpdate = (evt) => {
  const scopeKey = evt.target.dataset ? evt.target.dataset.scope : null;
  if (scopeKey) {
    this.$scope[scopeKey] = Scope.parseElementValue(evt.target);
  }
};

The handleUpdate method is performed on both change and input events (change was added for checkbox and radio button support) and utilizes the Scope.parseElementValue static method to update the corresponding ScopeItem that the currently targeted element is bound to.

// Updates the value of the ScopeItem that the elements `data-scope` attribute signifies on change
handleFocus = (evt) => {
  this.element = evt.target;
};

And last but not least, we have the handleFocus method which still does the same thing it did at the beginning of this post, it assigns the event target element to the element property triggering all the EventListener removal and addition.

BONUS ROUND!!!

Just because it really wasn't much more effort after all of that, I added the ability to add and remove DOM elements that utilize data-scope on the fly. We simply add a MutationObserver to the Scope class that watches for changes to the body and either adds an element to a ScopeItem's observers property when created, or removes one when deleted. Add the following to the Scope class.

export default class Scope {
  constructor() {
    ...
    this.target = new Target(this.proxy);
    this.observeMutations(); // Add this line
        ...
  }
    // Manages scope through any DOM manipulation events
  observeMutations = () => {
    // create an observer instance
    const observer = new MutationObserver(mutations => {
      mutations.forEach(mutation => {
        if (mutation.type === "childList") {
          mutation.addedNodes.forEach(elem => {
            this.handleNodeInserted(elem);
          });
          mutation.removedNodes.forEach(elem => {
            this.handleNodeRemoved(elem);
          });
        }
      });
    });

    // configuration of the observer:
    const config = { childList: true };

    // pass in the body as the target node, as well as the observer options
    observer.observe(document.querySelector("body"), config);
  };

  // Called from the MutationObserver when an element is inserted into the DOM
  handleNodeInserted = element => {
    const scopeKey = element.dataset ? element.dataset.scope : null;
    if (scopeKey) {
      this.subscribeToScope(element);
    }
  };

  // Called from the MutationObserver when an element is removed from the DOM
  handleNodeRemoved = element => {
    const scopeKey = element.dataset ? element.dataset.scope : null;
    if (scopeKey) {
      this.$scope[scopeKey].removeObserver(element);
    }
  };
    ...
}
Discover and read more posts from Joe Tagliaferro
get started