Nikita Vasilyev

Two-Way Data Binding

Let’s build a temperature converter app in Backbone, React, Angular, Meteor and vanilla JavaScript.

Vanilla JS

Vanilla JS is our baseline. Input values are synchronised using two event handlers, one on each input field.


function c2f(c) {
	return 9/5 * c + 32
}
function f2c(f) {
	return 5/9 * (f - 32)
}

var celsius = document.getElementById('celsius')
var fahrenheit = document.getElementById('fahrenheit')
fahrenheit.value = c2f(celsius.value)

celsius.oninput = function(e) {
	fahrenheit.value = c2f(e.target.value)
};

fahrenheit.oninput = function(e) {
	celsius.value = f2c(e.target.value)
};

Backbone.js


var Temperature = Backbone.Model.extend({
	defaults: {
		celsius: 0
	},
	fahrenheit: function(value) {
		if (typeof value == 'undefined') {
			return c2f(this.get('celsius'))
		}
		this.set('celsius', f2c(value))
	}
})

var TemperatureView = Backbone.View.extend({
	el: document.getElementById('tc-backbone'),
	model: new Temperature(),
	events: {
		'input .celsius': 'updateCelsius',
		'input .fahrenheit': 'updateFahrenheit'
	},
	initialize: function() {
		this.listenTo(this.model, 'change:celsius', this.render)
		this.render()
	},
	render: function() {
		this.$('.celsius').val(this.model.get('celsius'))
		this.$('.fahrenheit').val(this.model.fahrenheit())
	},
	updateCelsius: function(event) {
		this.model.set('celsius', event.target.value)
	},
	updateFahrenheit: function(event) {
		this.model.fahrenheit(event.target.value)
	}
})

var temperatureView = new TemperatureView()

Temperature is our model. Note that it only stores °C values, it doesn’t store °F. We can always convert one to another so there is no need to store both.

View→Model→View Blowback

Changing the value in the text field moves the cursor to the end. The problem is that data flows from an input field to a model, and then back to the same input field, overriding the current value even if it’s exactly the same.

There are workarounds.

React.js

var TemperatureConverter = React.createClass({
	getInitialState: function() {
		return {c: 0}
	},
	render: function() {
		var celciusValueLink = {
			value: this.state.c.toString(),
			requestChange: this.onCelsiusChange
		}
		var fahrenheitValueLink = {
			value: c2f(this.state.c).toString(),
			requestChange: this.onFahrenheitChange
		}
		return <div>
			<input type="number" valueLink={celciusValueLink}/>°C
			<span>  </span>
			<input type="number" valueLink={fahrenheitValueLink}/>°F
		</div>
	},
	onCelsiusChange: function(data) {
		this.setState({c: parseFloat(data)})
	},
	onFahrenheitChange: function(data) {
		this.setState({c: f2c(data)})
	}
})

React.renderComponent(
	<TemperatureConverter/>,
	document.body
)

React.js doesn’t have Backbone’s problem with moving the cursor position. Its virtual DOM, a layer between the actual DOM and React’s state, prevents React from unnecessary DOM changes.

setState schedules re-rendering on next requestAnimationFrame. render method updates the virtual DOM, calculates the difference between the current and the previous virtual DOM objects, and applies the changes to the actual DOM.

However, here is another bug (Backbone has it too):

Double Conversion

Instead of 2 we get 1.9999999999999964, because:

c2f(f2c(2)) === 1.9999999999999964

The problem is in the double conversion: Fahrenheits to Celsius, and then back to Fahrenheits. In many programming languages, including JavaScript, all arithmetic operations are performed in floating point, and floating point operations aren't necessarily precise.

0.2 + 0.1 = 0.30000000000000004

Sophie Alpert, a former core developer of React, suggested two different solutions.

Angular.js

Angular.js doesn't have the problems mentioned previously since it doesn’t update the input field that changed.

HTML

<div ng-app="temperature-converter">
	<input type="number" ng-model="c">°C 
	<input type="number" ng-model="c" converter="c2f">°F
</div>

JS

var app = angular.module('temperature-converter', []);

app.directive('converter', function(converters) {
	return {
		require: 'ngModel',
		link: function(scope, element, attr, ngModel) {
			var converter = converters[attr.converter]
			ngModel.$formatters.unshift(converter.formatter)
			ngModel.$parsers.push(converter.parser)
			$scope.c = 0
		}
	}
})

app.value('converters', {
	c2f: {
		formatter: c2f,
		parser: f2c
	}
})

Meteor

Meteor, like Angular, doesn’t have the mentioned problems either.

HTML

<body>
	{{> temperatureConverter}}
</body>

<template name="temperatureConverter">
	<input type="number" value="{{celsius}}" class="celsius">°C 
	<input type="number" value="{{fahrenheit}}" class="fahrenheit">°F
</template>

JS

Session.setDefault('c', 0)

Template.temperatureConverter.celsius = function() {
	return Session.get('c')
};
Template.temperatureConverter.fahrenheit = function() {
	return c2f(Session.get('c'))
};

Template.temperatureConverter.events({
	'input .celsius': function(e) {
		Session.set('c', parseFloat(e.target.value))
	},
	'input .fahrenheit': function(e) {
		Session.set('c', f2c(e.target.value))
	}
})

Summary

Backbone doesn’t support two-way data binding out of the box. It’s the only library here that overwrites the currently edited input field with the same value.

React, Angular and Meteor all support two-way data binding. Although, in my example React needed a little extra logic to handle conversion errors.

Fortunately, none of the mentioned libraries go into an infinite loop updating values back and forth between model and view.


Feel free to remake the vanilla.js example using your favorite framework and comment bellow.


Syntax highlighting in the article was inspired by “Coding in color: How to make syntax highlighting more useful”.

Thanks to Nick Porter for helping with Angular, Yuriy Dybskiy for reviewing my Meteor code, and Adam Solove for copy editing the whole thing.

Published by
Nikita Vasilyev
· Updated