In this blog post I’ll show you how you could implement two way data binding with JavaScript.

What is data binding?

Data binding means havin a data model somewhere, that is automatically updated from the ui; like input fields.

How to implement data binding with JavaScript and HTML

This is what I came up with:

This is just an experiment and by no means perfect. Use with care!

Let’s use a very simple syntax to defines values to be bound directly in html, like this:

<input
  id="test"
  type="text"
  :value="username"
  :placeholder="usernamePlaceholder"
/>

So we want to bind the input value and placeholder attribute to this sample data object:

let store = {
  username: '',
  usernamePlaceholder: 'Input a username'
};

First, iterate over all elements that have our special :[attribute] syntax and initialize it. For every value attribute I added a input listener to update out store.

[].forEach.call(document.querySelectorAll('*'), e => {
  e.getAttributeNames().forEach(attr => {
    if (attr.startsWith(':')) {
      const propertyName = e.getAttribute(attr);
      const attributeName = attr.split(':')[1];

      if (store[propertyName] !== undefined)
        e[attributeName] = store[propertyName];
      if (attributeName === 'value')
        e.addEventListener('input', () => {
          store[propertyName] = e[attributeName];
        });
    }
  });
});

To also update other attributes like placeholder, I used a MutationObserver. I added a boundAttributes object that stores all bound attributes. Then I added the MutationObserver to every element to update the store when a bound attribute is updated:

[].forEach.call(document.querySelectorAll('*'), e => {
  const boundAttributes = {};
  e.getAttributeNames().forEach(attr => {
    if (attr.startsWith(':')) {
      const propertyName = e.getAttribute(attr);
      const attributeName = attr.split(':')[1];

      if (store[propertyName] !== undefined)
        e[attributeName] = store[propertyName];
      if (attributeName === 'value')
        e.addEventListener('input', () => {
          store[propertyName] = e[attributeName];
        });

      boundAttributes[attributeName] = propertyName;
    }
  });
  const observer = new MutationObserver(muations =>
    muations.forEach(m => {
      const prop = boundAttributes[m.attributeName];
      if (
        m.type === 'attribute' &&
        prop &&
        store[prop] !== e[m.attributeName]
      ) {
        store[prop] = e[m.attributeName];
      }
    })
  );
  observer.observe(e, {
    attributes: true
  });
});

Now if we update a attribute that was bound, like :placeholder="usernamePlaceholder" the corresponding property in the store is automatically updated:

document.getElementById('test').placeholder = 'test';

console.log(store.usernamePlaceholder); // => test

Now it only updates our store when we change out HTML Element. To also update our HTML Elements when changing our store, we can wrap our store in a Proxy:

store = new Proxy(store, {
  set: (obj, prop, value) => {
    // Handle updates her
    obj[prop] = value;
    return true;
  },

I saved a function for updating every HTML Element in a storeListener object to access it in our Proxy:

const storeListener = {};
[].forEach.call(document.querySelectorAll('*'), e => {
  const boundAttributes = {};
  e.getAttributeNames().forEach(attr => {
    if (attr.startsWith(':')) {
      const propertyName = e.getAttribute(attr);
      const attributeName = attr.split(':')[1];

      if (store[propertyName] !== undefined)
        e[attributeName] = store[propertyName];
      if (attributeName === 'value')
        e.addEventListener('input', () => {
          store[propertyName] = e[attributeName];
        });

      // define a function to be used in our proxy
      storeListener[propertyName] = value => {
        e[attributeName] = value;
      };
      boundAttributes[attributeName] = propertyName;
    }
  });
  const observer = new MutationObserver(muations =>
    muations.forEach(m => {
      const prop = boundAttributes[m.attributeName];
      if (
        m.type === 'attribute' &&
        prop &&
        store[prop] !== e[m.attributeName]
      ) {
        store[prop] = e[m.attributeName];
      }
    })
  );
  observer.observe(e, {
    attributes: true
  });
});
return new Proxy(store, {
  set: (obj, prop, value) => {
    // update our html element:
    if (obj[prop] !== value && storeListener[prop]) storeListener[prop](value);
    obj[prop] = value;
    return true;
  }
});

Finally I wrapped everything in a function and added a onUpdate function to be able to react on updated values.

const bind = (store, onUpdate) => {
  const storeListener = {};
  [].forEach.call(document.querySelectorAll('*'), e => {
    const boundAttributes = {};
    e.getAttributeNames().forEach(attr => {
      if (attr.startsWith(':')) {
        const propertyName = e.getAttribute(attr);
        const attributeName = attr.split(':')[1];

        if (store[propertyName] !== undefined)
          e[attributeName] = store[propertyName];
        if (attributeName === 'value')
          e.addEventListener('input', () => {
            store[propertyName] = e[attributeName];
            onUpdate(propertyName, store[propertyName]);
          });

        storeListener[propertyName] = value => {
          e[attributeName] = value;
          onUpdate(propertyName, value);
        };
        boundAttributes[attributeName] = propertyName;
      }
    });
    const observer = new MutationObserver(muations =>
      muations.forEach(m => {
        const prop = boundAttributes[m.attributeName];
        if (
          m.type === 'attribute' &&
          prop &&
          store[prop] !== e[m.attributeName]
        ) {
          store[prop] = e[m.attributeName];
          onUpdate(store[prop], e[m.attributeName]);
        }
      })
    );
    observer.observe(e, {
      attributes: true
    });
  });
  return new Proxy(store, {
    set: (obj, prop, value) => {
      if (obj[prop] !== value && storeListener[prop])
        storeListener[prop](value);
      obj[prop] = value;
      return true;
    }
  });
};

This can now easily be called liked this:

let store = {
  username: '',
  usernamePlaceholder: 'Input a username'
};
store = bind(store, (key, value) => console.log(`${key} set to ${value}`));
store.username = 'Tim';

Demo

See it in action here. This should now display 'Tim' as value in the input field, and log the value in the browser developer console, if you input anything. Try updating the store in the browser console like store.username = 'Thomas';: