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="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';
Now when typing something in the input field, the console will log changes 😎
username set to Tim