Skip to main content Accessibility Feedback

Two-way data binding and reactivity with 15 lines of vanilla JavaScript

Last week, I had a chat with one of my students in my private Slack team about how to keep two copies of the same form in sync with each other.

I shared a simple trick that uses JS Proxies and 15 lines of vanilla JS.

Let’s dig in!

The HTML

For this example, let’s imagine we have two forms on our page. They both have the same fields in them, but they’re in two totally different locations.

The [name] attributes for the fields are identical, but we’re using unique IDs for the input and textarea elements, because IDs must be unique and only used once.

Each form also has a [data-form-sync] attribute that we’ll hook into in our JavaScript.

<form data-form-sync>

	<label for="title-1">Title</label>
	<input type="text" name="title" id="title-1" value="Go to the beach">

	<label for="body-1">Body</label>
	<textarea id="body-1" name="body">Soak up the sun and swim in the ocean.</textarea>

	<button>Submit</button>

</form>

<form data-form-sync>

	<label for="title-2">Title</label>
	<input type="text" name="title" id="title-2" value="Go to the beach">

	<label for="body">Body</label>
	<textarea id="body-2" name="body">Soak up the sun and swim in the ocean.</textarea>

	<button>Submit</button>

</form>

What’s two-way data binding?

For this challenge, we want to use two-way data binding.

What that means is that whenever one of our form fields is updated, we want to update some data object with its value. And, whenever the data object is updated, we want to update all corresponding fields with that value, too.

// Each key in this object matches a field [name] in the form
// This object and the forms should always be in-sync
let data = {
	title: 'Go to the beach',
	body: 'Soak up the sun and swim in the ocean.'
};

Proxies FTW!

To make this work, we can use a JavaScript Proxy.

A Proxy object is a wrapper around an array or object that watches for changes to the object properties, and lets you run code in response.

We’ll create a new Proxy with an empty object ({}) by using the new Proxy() constructor. We also need to pass in an object of handler methods as a second argument. We’ll pass in an empty object ({}) for now.

// Create a new Proxy
let data = new Proxy({}, {});

Proxies have a handful of trap methods that catch different types of object property changes.

For this challenge, we want to use the set() method, which runs whenever a property value is added or updated. For simplicity, we’ll use the object property shorthand syntax.

It receives the object, key to update, and value to use as arguments.

For now, we’ll just implement the default object behavior: update the property value and return true.

// Create a new Proxy
let data = new Proxy({}, {
	set (obj, key, value) {
		obj[key] = value;
		return true;
	}
});

Binding fields to the data object

To update our fields whenever the data object is updated, we can use the document.querySelectorAll() method to find all of the fields whose [name] attribute has a value equal to the object key we’re updating.

Then, we’ll loop through each field, and update it’s value property to match the new object property value.

// Create a new Proxy
let data = new Proxy({}, {
	set (obj, key, value) {

		// Update the property
		obj[key] = value;

		// Find the matching fields in the DOM
		let fields = document.querySelectorAll(`[name="${key}"]`);
		for (let field of fields) {
			field.value = value;
		}

		return true;

	}
});

Now, if we updated our data object, our form fields get automatically updated, too.

// These automatically trigger a UI update
data.title = 'Hello, world!';
data.body = 'How are you today?';

Binding the data object to the form fields

To keep both of our forms in sync, we need to update our data object whenever one of them is updated.

We can do that with an input event listener, which runs whenever a form field value is updated.

// Event listener
document.addEventListener('input', function (event) {
	// A form field was updated...
});

Inside our callback function, we’ll first use the Element.prototype.closest() method to make sure the field (or event.target) is inside a form with the [data-form-sync] attribute.

If not, we’ll return early to end the function.

// Event listener
document.addEventListener('input', function (event) {

	// Only run on our forms
	if (!event.target.closest('[data-form-sync]')) return;

});

Otherwise, we can update our data object.

We’ll use the field name property as the key, and the field value as the value. This update triggers our Proxy’s handler to run, updating any other fields with the same name.

// Event listener
document.addEventListener('input', function (event) {

	// Only run on our forms
	if (!event.target.closest('[data-form-sync]')) return;

	// Update the data object
	data[event.target.name] = event.target.value;

});

See it in action

Here’s a demo you can play with.

As you type or update fields in one field, the other stays in-sync. The demo code is 28 lines, but once you strip out comments, it’s just 15 lines of vanilla JavaScript.