profile image linking to the home page
PostsTwo way data binding with vanilla JavaScript

Two way data binding with vanilla JavaScript

This is an old post from 2020. I'm keeping it here for archival purposes.

What is data binding?

Data binding means having a data model somewhere, that is automatically synced with the ui, like input fields.

An experimental way to implement data binding with vanilla 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"
/>

Note: This is not a valid html syntax. But because browsers are very forgiving, it works. 😎

So, now 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 an input listener to update our 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 placeholders, 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=&quot;usernamePlaceholder&quot; 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';

Now when typing something in the input field, the console will log changes 😎

username set to Tim