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.

May 1, 20264 min read5 / 6

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

  1. 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.
  2. form.elements: A built-in DOM collection that lets you access form inputs by their name attribute as if they were object properties. form.elements.email gives you the email input.
  3. Proxy for data-to-form sync: When the data object changes, the proxy set trap updates the corresponding form input.
  4. Event listener for form-to-data sync: When the user changes an input, a change event updates the data object.
  5. No infinite loop: Setting form.elements.name.value from JavaScript does not fire the change event. The change event 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:

JavaScript
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; // SyntaxError

In 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

JavaScript
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 set trap writes to target[property] (the data object) and also to form.elements[property].value (the corresponding input). Changing this.#user.name updates the name input.
  • Form to data: Each input listens for change. When the user edits an input, it writes back to this.#user through the proxy, which also re-triggers the set trap -- but that trap writing to form.elements[property].value from JavaScript does not fire change again, 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:

JavaScript
form.elements[0]; // first control form.elements['email']; // input with name="email" form.elements.email; // same thing, dot notation

This 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

JavaScript
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:

JavaScript
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

Video: