Double Data Binding
Two-way binding between a form and a data object using a Proxy. Change the object and the form updates. Change the form and the object updates. No framework required.
Forms are the place where vanilla JS feels most verbose compared to frameworks. In React you write value={state.name} and the input stays in sync. In vanilla JS you wire the connection yourself. But with a Proxy, it takes about 15 lines and you understand exactly what is happening.
The Essentials
- Private class fields with
#: Properties prefixed with#are private to the class. No external code can read or set them. This is native JavaScript, not TypeScript. form.elements: A built-in DOM collection that lets you access form inputs by theirnameattribute as if they were object properties.form.elements.emailgives you the email input.- Proxy for data-to-form sync: When the data object changes, the proxy
settrap updates the corresponding form input. - Event listener for form-to-data sync: When the user changes an input, a
changeevent updates the data object. - No infinite loop: Setting
form.elements.name.valuefrom JavaScript does not fire thechangeevent. Thechangeevent only fires on user interaction, so the two directions do not feed each other.
Private Class Fields
Class properties prefixed with # are inaccessible from outside the class:
class OrderPage extends HTMLElement {
#user = {
name: '',
phone: '',
email: ''
};
// Inside the class, access as this.#user
connectedCallback() {
console.log(this.#user.name); // works
}
}
// Outside the class:
const page = document.querySelector('order-page');
page.#user; // SyntaxErrorIn TypeScript you would use the private keyword. In JavaScript, # is the native mechanism -- the property is not just conventionally private, it is enforced by the language.
The setFormBindings Method
setFormBindings(form) {
this.#user = new Proxy(this.#user, {
set(target, property, value) {
target[property] = value;
form.elements[property].value = value;
return true;
}
});
Array.from(form.elements).forEach(element => {
element.addEventListener('change', () => {
this.#user[element.name] = element.value;
});
});
}Two directions:
- Data to form: The Proxy
settrap writes totarget[property](the data object) and also toform.elements[property].value(the corresponding input). Changingthis.#user.nameupdates the name input. - Form to data: Each input listens for
change. When the user edits an input, it writes back tothis.#userthrough the proxy, which also re-triggers thesettrap -- but that trap writing toform.elements[property].valuefrom JavaScript does not firechangeagain, so there is no loop.
The form.elements API
form.elements is an HTMLFormControlsCollection. It is indexed both numerically and by the name attribute of each control:
form.elements[0]; // first control
form.elements['email']; // input with name="email"
form.elements.email; // same thing, dot notationThis lets the proxy trap write form.elements[property].value = value where property is the field name from the data object -- no querySelector needed, no manual ID wiring.
HTMLFormControlsCollection does not have forEach, so wrap it in Array.from when you need to iterate.
Form Submit Without a Page Reload
form.addEventListener('submit', event => {
event.preventDefault();
alert(`Thanks for your order, ${this.#user.name}! Receipt sent to ${this.#user.email}.`);
// Clear the form by clearing the data object
this.#user.name = '';
this.#user.phone = '';
this.#user.email = '';
});event.preventDefault() stops the browser from submitting the form with a GET or POST request to the server -- the default behavior that would navigate away from the SPA. After that, the data is available in this.#user without querying any DOM elements. The proxy binding took care of keeping it in sync.
Clearing this.#user clears the form inputs automatically, because the proxy's set trap writes form.elements[property].value = value for each assignment.
Why Use the Shadow DOM querySelector for the Form
When the order page is a Shadow DOM component, document.querySelector('form') returns null. The form lives in the shadow root, not the main document. Use the shadow root to query:
render() {
const form = this.root.querySelector('form');
this.setFormBindings(form);
}this.root is the shadow root attached in the constructor. It has its own querySelector that searches within the shadow tree.
Further Reading and Watching
- MDN: Private class features - Full reference for the
#private field syntax in JavaScript. - MDN: HTMLFormControlsCollection - The collection returned by
form.elements, including how named access works.
Video:
- JavaScript Proxy in 15 Minutes by Fireship. The pattern used in setFormBindings is a direct application of the set trap covered in this video.
Keep reading