Skip to content

Instantly share code, notes, and snippets.

@austinhyde
Last active August 16, 2023 18:19
Show Gist options
  • Save austinhyde/4321f22a476e1cbee65f to your computer and use it in GitHub Desktop.
Save austinhyde/4321f22a476e1cbee65f to your computer and use it in GitHub Desktop.
Vanilla JavaScript Data Binding

Observables

You don't really need a framework or fancy cutting-edge JavaScript features to do two-way data binding. Let's start basic - first and foremost, you need a way to tell when data changes. Traditionally, this is done via an Observer pattern, but a full-blown implementation of that is a little clunky for nice, lightweight JavaScript. So, if native getters/setters are out, the only mechanism we have are accessors:

var n = 5;
function getN() { return n; }
function setN(newN) { n = newN; }

console.log(getN()); // 5
setN(10);
console.log(getN()); // 10

That's a little boring though, let's rearrange that to just a single function:

var _n = 5;
function n(n) {
  if (arguments.length) _n = n;
  return _n;
}

console.log(n()); // 5
n(10);
console.log(n()); // 10

Now, suppose we want to notify stuff when the value changes...

var _n = 5, _nListeners = [];
function n(n) {
  if (arguments.length && n !== _n) {
    _n = n;
    _nListeners.forEach(function(listener) { listener(n); });
  }
  return _n;
}
n.subscribe = function(listener) { _nListeners.push(listener); }

console.log(n()); // 5
n.subscribe(function(newN) { console.log(newN); });
n(10); // logs 10
n(10); // no output, value didn't change.

Note that we don't notify subscribers that the value changed, if the value didn't actually change!

That's a real pain in the ass if we want to be doing that a lot, so let's wrap it up in a neat little generator function:

function observable(value) {
  var listeners = [];

  function notify(newValue) {
    listeners.forEach(function(listener){ listener(newValue); });
  }

  function accessor(newValue) {
    if (arguments.length && newValue !== value) {
      value = newValue;
      notify(newValue);
    }
    return value;
  }

  accessor.subscribe = function(listener) { listeners.push(listener); };

  return accessor;
}

var n = observable(5);
n.subscribe(function(newN) { console.log(newN); });
n(10); // logs 10

Cool! Now we're getting somewhere! Using observable(), we can now have as many little pre-packaged observable values as we want!

The next step is learning how to combine them. Suppose we want to do some basic math and add two observables together. We can't just do c(a() + b()), because c won't know when a or b change - it will get set once, but that's it. What we need to do is subscribe to changes on a or b, so that when either of them change, we update c:

var a = observable(3), b = observable(2);

var c = observable(a() + b());

a.subscribe(function(){ c(a() + b()); });
b.subscribe(function(){ c(a() + b()); });

console.log(c()); // 5
a(10);
console.log(c()); // 12
b(7);
console.log(c()); // 17

Now, if you're feeling clever, you'll notice that there's a lot of repetition going on there. We can fix that by pulling out functions:

var a = observable(3), b = observable(2);

function calculation() { return a() + b(); }

var c = observable(calculation());

function listener() { c(calculation()); }
a.subscribe(listener);
b.subscribe(listener);

As it happens, updating dependent values this way turns out to be very common. In fact, it's really the same thing as normal JavaScript operations, except in an observable way. Wouldn't it be cool if we could automatically set up those subscriptions?

Let's start by wrapping up the boilerplate above. We need a way to calculate the value of the observable, and we need to know the observables that participate in that calculation. We'll call this variation on an observable a computed value:

function computed(calculation, dependencies) {
  // start with the initial value
  var value = observable(calculation());

  // register a listener for each dependency, that updates the value
  function listener() { value(calculation()); }
  dependencies.forEach(function(dependency) {
    dependency.subscribe(listener);
  });

  // now, wrap the value so that users of computed() can't manually update the value
  function getter() { return value(); }
  getter.subscribe = value.subscribe;

  return getter;
}

Note that we wrap the observable that we're calculating the value of in a read-only version. Because what would it mean for code to manually set the value of a calculation? If you literally say that, for example, c is the sum of a + b, it really doesn't make much sense to come along later and set c to five. What happens if a or b update? They'd overwrite that value anyways. Therefore, we avoid any confusion by returning a read-only accessor.

Let's put this to use:

var a = observable(3), b = observable(2);
var c = computed(function(){ return a() + b(); }, [a, b]);

console.log(c()); // 5
a(10);
console.log(c()); // 12
b(7);
console.log(c()); // 17

Woo hoo! Now we're chugging!

Binding

The second big hurdle we need to clear is data binding. Frameworks like Angular and React tackle this in big, complex, scary ways. I don't know about you, but I don't like scary.

Let's continue with the adding example, but now let's represent it with text boxes:

<input type="text" id="a-text">
+
<input type="text" id="b-text">
=
<input type="text" id="c-text" readonly>

The first challenge is how to get our observables into the text boxes. Turns out, that's pretty easy (assuming a, b, c from above):

var aText = document.getElementById('a-text');
aText.value = a();
a.subscribe(function(_a){ aText.value = _a; });

var bText = document.getElementById('b-text');
bText.value = b();
b.subscribe(function(_b){ bText.value = _b; });

var cText = document.getElementById('c-text');
cText.value = c();
c.subscribe(function(_c){ cText.value = _c; });

Sigh again with the repetition. Let's clean that up:

function bindValue(input, observable) {
  input.value = observable();
  observable.subscribe(function(){ input.value = observable(); });
}

bindValue(aText, a);
bindValue(bText, b);
bindValue(cText, c);

Much better! The second half of the problem is updating our values when the text boxes change. That's pretty easy too, actually. All we need to do is listen to events on the input, and update the observable accordingly. Let's just update our bindValue function:

function bindValue(input, observable) {
  input.value = observable();
  observable.subscribe(function(){ input.value = observable(); });

  input.addEventListener('input', function() {
    observable(input.value);
  });
}

Now, whenever the textbox value changes, we'll update the observable, and when the observable changes, we update the textbox. We have lift-off!

Well, actually, only in theory. We need to make a slight adjustment to our bindValue function for this particular example - we've been doing math with integers, but text box values are strings. Adding two strings concatenates them, it doesn't add their numeric values!

Option one is to just force the value to an integer, but of course, that limits our function to ONLY working with integers, and that's not very much fun. Instead, let's do a little sniffing to figure out what we want to do...

function bindValue(input, observable) {
  var initial = observable();
  input.value = initial;
  observable.subscribe(function(){ input.value = observable(); });

  var converter = function(v) { return v; };
  if (typeof initial == 'number') {
    converter = function(n){ return isNaN(n = parseFloat(n)) ? 0 : n; };
  }

  input.addEventListener('input', function() {
    observable(converter(input.value));
  });
}

Now, if the initial value of the observable is a number, we try to interpret further values of the input as a number as well.

You can see a working example of the observables and bindings, as well as a complete listing of the above code on JSBin

Conclusion

As you can see, we were able to implement a proof-of-concept two-way data-binding example using nothing but vanilla JavaScript, that's compatible all the way back to IE 6!

Not only is our solution lightweight, with the whole "framework" being less than 50 lines total, but using it is very nearly as easy and expressive as its non-observable counterparts.

In fact, it turns out you can take this simple implementation very, very far. I'll leave it as an exercise to the reader to implement, but using this code as a base, you can:

  • Automatically detect referenced observables in computeds
  • Manage entire observable arrays and objects containing other observables
  • Have properly managed cyclic dependencies between computeds
  • Implement many many many differnet bindings from observables to DOM, not just input values
  • Declare bindings as data attributes directly in the HTML, and apply them wholesale
  • and much, much more.

How do I know you can do this? Because this is exactly the same approach that Steve Sanderson's (amazing, excellent, stupdendous) Knockout library takes. What I listed here - observables, computeds, and DOM bindings, are the core foundations of Knockout. What I love most about it though, is that I can summarize how Knockout works in 50 lines of code, by iteration on a very simple idea.

For some reference, here's what this whole example looks like in Knockout:

<input type="text" data-bind="numericValue: a">
+
<input type="text" data-bind="numericValue: b">
=
<input type="text" data-bind="numericValue: a() + b()" readonly>

<script>
ko.bindingHandlers.numericValue = {
  init: function(element, valueAccessor) {
    var observable = valueAccessor();
    element.addEventListener('input', function() {
      var v = element.value;
      observable(isNaN(v = parseFloat(v)) ? 0 : v);
    });
  },
  update: function(element, valueAccessor) {
    element.value = ko.unwrap(valueAccessor());
  }
};

ko.applyBindings({
  a: ko.observable(3),
  b: ko.observable(2)
});
</script>

Note that while Knockout does supply a 'value' binding out of the box, it doesn't attempt to parse numbers like we need it to for this example. So, as is the Knockout way, we make our own binding to handle that case.

@katopz
Copy link

katopz commented Apr 11, 2016

This longest gist I ever read. Pretty cool!

@abacaj
Copy link

abacaj commented Apr 12, 2016

This is good, make it longer and a blog post - you got a hit.

@steveshore
Copy link

How do you handle event listeners? Would you use the data-bind attributes like they do?

@mdublin
Copy link

mdublin commented Mar 9, 2017

Very cool

@SaleRise
Copy link

SaleRise commented Sep 10, 2017

I really like this approach which is unlike others where people use time polling and the likes and then update the object.

I am wondering how will it work in observables are on the property in the data object like:

data_obj = {
  a: 4,
  b: 5
}

var e = observable(data_obj.a), f = observable(data_obj.b);
var c = computed(function(){ return e() + f(); }, [e, f]);
var uptodate = observable(data_obj);
setInterval(function() { 
  console.log(e() +" "+ f());
  console.log(uptodate());
                       }, 2000);

How can we get up-to-date value of the data object, ofcourse this data object can be complex with nested object?

Thanks for the really helpful share!

@easymagic
Copy link

Great article.

@nkev
Copy link

nkev commented Jan 10, 2018

Beautifully worded article. 🥇 More please!

@sreid70
Copy link

sreid70 commented Mar 23, 2018

Thanks. I've been using frameworks like Angular and Vue and I do like them. I think they've really helped me structure my code a little better. Unfortunately, I start to use them as a crutch and stop learning how to do things in plain old JS. JS has come a long way and I know I should stop reaching for a framework every time I start a project. Thanks for the great introduction to observables and binding.

@planetarydev
Copy link

Thanks for this nice and simple understanding article.

@timmd909
Copy link

Hope you're doing well, Austin! I'm glad my memory bookmarked this page. Shooting this link to explain KO saves a lot of time.

@easymagic
Copy link

Great Article, Thumbs Up.

@kostasx
Copy link

kostasx commented Apr 18, 2020

Excellent read! You just laid out the foundations of almost every frontend framework out there. Congratulations and thank you.

@Jaballadares
Copy link

Jaballadares commented May 30, 2021

Thank you for writing this up! Learned a ton.

Apologies for the beginner-ish question - I noticed that if I changed how we add the subscribe method to accessor to an arrow function

accessor.subscribe = (listener) => listeners.push(listener);
// 1, 10

vs

accessors.subscribe = (listener) => { listeners.push(listener); };
// 10

I tried Googling for an answer, but can anyone explain why the curly braces prevent the 1 from showing up? Does it have to do with the implicit return of arrow functions without curly braces?

Update
Oddly enough if I add the return keyword the 1 shows up again at runtime

accessors.subscribe = (listener) => { return listeners.push(listener) }
// 1, 10

@kostasx
Copy link

kostasx commented May 31, 2021

You are exactly right @Jaballadares. It's all about the behavior of arrow functions with and without curly braces (explicit vs implicit return).

Without a return statement, you only get the execution of the push method. With the return statement you also get the value returned by this method.

@Jaballadares
Copy link

You are exactly right @Jaballadares. It's all about the behavior of arrow functions with and without curly braces (explicit vs implicit return).

Without a return statement, you only get the execution of the push method. With the return statement you also get the value returned by this method.

Thank you so much for taking the time to respond @kostasx! Much appreciated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment