When dealing with HTML <input>
in JavaScript, we usually want to handle one use case: user I/O via the input
, change
, and other similar events.
This can easily be done with the following code:
// Suppose we have an <input type="text"> element
const inputElem = document.querySelector('input');
inputElem.addEventListener('input', function(event) {
// Do something on user I/O
});
But what happens when the input is being updated by other JavaScript code we have little control over? Perhaps via some legacy JS library with sparse or non-existent documentation? Or some two-way data bindings with complicated spaghetti logic all over the application?
Then it might be time to go directly to the source: the value
property on the HTMLInputElement itself.
value
is a simple property that can be used via JavaScript to get and set the value of the HTML input element. It represents the DOM interface for JavaScript I/O to and from the <input>
.
Since it is easy to use and well-documented we don’t just have to stop at getting the value, we can make it reactive using the Object.defineProperty() API with just a few lines of code:
const rxDom = (elem, propertyName) => {
let innerValue = elem[propertyName], subscribers = [];
Object.defineProperty(elem, propertyName, {
get() {
return innerValue;
},
set(value) {
const newValue = value, oldValue = innerValue;
innerValue = newValue;
if (newValue === oldValue) return;
subscribers.forEach(s => s(newValue, oldValue));
},
configurable: true,
enumerable: true
});
const subscribe = callback => {
subscribers.push(callback);
};
const unsubscribe = callback => {
const index = subscribers.indexOf(callback);
if (index < 0) return;
subscribers.splice(index, 1);
};
return {
elem,
subscribe,
unsubscribe
};
};
I call this bit of code rxDom and what it does is simple: it adds a getter and a setter pair to a HTML element and allows us to react to changes on the value
(or any) property as they happen in real-time.
We can pass in a function as a callback
via the subscribe
method that has access to the newValue
and the oldValue
and can execute code when they change.
let rxInput = rxDom(inputElem, 'value');
function handleChange(newValue, oldValue) {
console.log('value has changed to:', newValue);
}
rxInput.subscribe(handleChange);
// Change the value of the input element via JS
inputElem.value = 'Hello New World!';
Running this code will result in the following output to the console:
value has changed to: Hello New World!
Pretty easy, right?
We can also unsubscribe
in order to stop reacting to changes or when we are done doing one particular thing:
rxInput.unsubscribe(handleChange);
Interestingly enough, you can only define one pair of getters and setters for a DOM element. So using rxDom
multiple times on a single element will only work with the last pair that was defined for the property.
Here’s a short example with Object.defineProperty()
to illustrate:
const test = { a: 1 };
let innerVal = test.a;
Object.defineProperty(test, 'a', {
get() {
return innerVal;
},
set(value) {
console.log('Setter 1:', value);
innerVal = value;
}
});
Object.defineProperty(test, 'a', {
get() {
return innerVal;
},
set(value) {
console.log('Setter 2:', value);
innerVal = value;
}
});
Object.defineProperty(test, 'a', {
get() {
return innerVal;
},
set(value) {
console.log('Setter 3:', value);
innerVal = value;
}
});
test.a = 2;
test.a = 3;
test.a = 4;
test.a = 5;
Running this code will result in the following output to the console:
Setter 3: 2
Setter 3: 3
Setter 3: 4
Setter 3: 5
We can observe that only the last pair of getters and setters are called when we change the test.a
object property.
Besides using rxDom
as a fun little proof-of-concept and for exploring JavaScript property descriptors, I wouldn’t really recommend using it due to how it mutates DOM elements, introduces side-effects into your code, and makes it harder to keep track of how things are changing.
If you have to resort to this type of pattern, it’s likely that there is a better solution or it might be time to seriously refactor your code.
…Your
Ian MalcolmScientistsJavaScript Developers Were So Preoccupied With Whether Or Not They Could, They Didn’t Stop To Think If They Should.
Just because we can do it, doesn’t mean we should.
You can do it without mutating the DOM element using Proxy (doesn’t work in IE).
const rxDom = (element, propertyName) => {
const subscribers = [];
const proxy = new Proxy(element, {
get(obj, prop) {
return obj[prop];
},
set(obj, prop, value) {
if (prop === propertyName) {
const oldValue = obj[prop];
if (value === oldValue) return true;
subscribers.forEach(s => s(value, oldValue));
}
obj[prop] = value;
return true;
}
});
const subscribe = (callback) => {
subscribers.push(callback);
};
const unsubscribe = (callback) => {
const index = subscribers.indexOf(callback);
if (index console.log(`Changed from ${oldVal} to ${newVal}`);
rxInput.subscribe(handleChange);
console.log(rxInput.element.value);
rxInput.element.value = “world”;
console.log(rxInput.element.value);
LikeLike
Yes, absolutely. Proxies are very powerful and make dealing with reactivity in JavaScript much easier.
One interesting detail is that they don’t mutate the target object, which is great for writing cleaner code. In this example, however, Object.defineProperty() can be used for reacting to changes that are already taking place vs. introducing a new Proxy object and re-writing existing code to support it.
LikeLike