+ - 0:00:00
Notes for current slide
Notes for next slide

From Backbone To React

Jof Arnold and Benjie Gillam

Timecounts.org

Timecounts: a platform for building and managing communities

The original stack

Backbone MVC

Rendr (isomorphic lib)

jQuery

Handlebars

Problems

Storing state in DOM

Layout changes breaking jQuery selectors

class='_element_to_reference' and other hacks

Re-rendering entire views

... a total nightmare to debug and test

React solves all of this...

Time to rebuild?

Noooooooo......!!!

50,000 existing LOC

Backbone still required for isomorphic framework

... rebuilding absolutely not an option

Solution

The plan

1. Policy: build all new views in React

2. Keep Backbone models and collections

(Bonus: rapid iteration means everything will end up React anyway. Which it pretty much has)

Warning! Hackery ahead!

What this isn't

The "correct" React (Flux) way of doing things

What this is

A quick method of adding React magic to your Backbone project without having to rebuild everything

Example

Backbone to Backbone+React

// ----- Backbone Router -----
var AppRouter = Backbone.Router.extend({
initialize: function() {
this.$rootEl = $("#content");
this.rootEl = this.$rootEl[0];
},
setView: function(view) {
if (this.view) {
this.view.remove();
}
this.view = view;
this.view.render();
this.$rootEl.append(this.view.el);
},
// ----------
routes: {
'': 'homeRoute',
'about': 'aboutRoute'
},
homeRoute: function() {
this.setView(new HomeView())
},
aboutRoute: function() {
this.setView(new AboutView())
}
});
// ----- About View -----
var AboutView = Backbone.View.extend({
template: _.template('<h1>About</h1><p>' +
'<img src="http://www.reactiongifs.com/r/1gjdAX7.gif"></p>'),
render: function() {
this.$el.html(this.template());
}
});
// ----- Home View -----
var HomeView = Backbone.View.extend({
template: _.template('<h1>Hello World!</h1>' +
'<p>Random number: <span><%- number %></span> (click for another)</p>' +
'<textarea>Notes...</textarea>'),
events: {
'click p': 'render'
},
randomNumber: function() {
return Math.floor(Math.random() * 100) + 1;
},
render: function() {
this.$el.html(this.template({
number: this.randomNumber()
}));
}
});

Not so fast!

Calling render() directly will overwrite everything, including our <textarea>, resetting its content

Instead we need to update just the <span> containing the random number:

// ----- Home View -----
var HomeView = Backbone.View.extend({
template: _.template('<h1>Hello World!</h1>' +
'<p>Random number: <span><%- number %></span> (click for another)</p>' +
'<textarea>Notes...</textarea>'),
events: {
'click p': 'newNumber'
},
randomNumber: function() {
return Math.floor(Math.random() * 100) + 1;
},
newNumber: function(e) {
this.$("span").html(this.randomNumber());
},
render: function() {
this.$el.html(this.template({
number: this.randomNumber()
}));
}
});

Convert to React

1. Add React support to the router

AppRouter = Backbone.Router.extend({
//...
setView: function(view) {
if (this.view) {
if (this.view instanceof Backbone.View) {
this.view.remove();
} else {
React.unmountComponentAtNode(this.rootEl);
}
}
this.view = view;
if (this.view instanceof Backbone.View) {
this.view.render();
this.$rootEl.append(this.view.el);
} else {
React.render(this.view, this.rootEl);
}
},
//...
});

2. Convert HomeView to React

var DOM = React.DOM;
var HomeView = React.createClass({
displayName: "HomeView",
getInitialState: function() {
return {
randomNumber: this.randomNumber()
};
},
randomNumber: function() {
return Math.floor(Math.random() * 100) + 1;
},
newNumber: function(e) {
this.setState({
randomNumber: this.randomNumber()
});
},
render: function() {
return DOM.div(null,
DOM.h1(null, "Hello World!"),
DOM.p({
onClick: this.newNumber
}, "Random number: " + this.state.randomNumber + " (click for another)"),
DOM.textarea(null, "Notes...")
)
}
});

What's changed?

randomNumber now stored in this.state: value accessible without reading the DOM

No fragile jQuery find-tag-then-replace-html nonsense

What about Backbone Models/Collections?

Where React + Backbone shines

Using a small mixin we can have React automatically re-render when any model/collection changes...

... so long as we store it on this.state

Backbone Listener

We track event listeners via a Backbone.Events object.

function BackboneListener(){}
_.extend(BackboneListener.prototype, Backbone.Events);
var BackboneMixin = {
getInitialState: function() {
return {
_backboneListener: new BackboneListener()
};
},

Hook the relevant lifecycle methods to keep subscriptions current and avoid leaks

componentDidMount: function() {
this._backboneSubscribeObjects(this.state);
},
componentWillUpdate: function(nextProps, nextState) {
this._backboneUnsubscribeObjects(this.state);
this._backboneSubscribeObjects(nextState);
},
componentWillUnmount: function() {
this.state._backboneListener.stopListening();
},

(Un)subscribing with _backboneListener

_backboneSubscribeObjects: function(hash, unsubscribe) {
var method = (unsubscribe === true ? 'stopListening' : 'listenTo');
var listener = this.state._backboneListener;
for (var key in hash) {
var obj = hash[key];
if (obj instanceof Backbone.Model) {
listener[method](obj, "change", this._forceUpdateOnce);
} else if (obj instanceof Backbone.Collection) {
listener[method](obj, "add remove reset sort change destroy sync",
this._forceUpdateOnce);
}
}
},
_backboneUnsubscribeObjects: function(hash) {
this._backboneSubscribeObjects(hash, true);
},

For efficiency, we don't call this.forceUpdate() for every single change - instead at most once per runloop

_forceUpdateOnce: function() {
if (this._forceUpdateOnceTimer) return;
var _this = this;
this._forceUpdateOnceTimer = setTimeout(function() {
delete _this._forceUpdateOnceTimer;
if (_this.isMounted()) {
return _this.forceUpdate();
}
}, 0);
},

Caveat: this makes cascading model/collection changes more efficient; however the update is not real time...

So if you have a managed input element with value: backboneModel.get('property') you must call this.forceUpdate() in your onChange handler after updating the model - e.g.

changedText: function(e) {
model.set('text', this.refs.text.getDOMNode().value);
this.forceUpdate(); //<-- VITAL
},
render: function() {
return DOM.input({ref: 'text',
value: this.state.model.get('text'),
onChange: this.changeText
});
}

Bonus helper methods, in case you want to hook the 'sync' event on a collection to refetch a dependent collection or similar

listenTo: function() {
var listener = this.state._backboneListener;
return listener.listenTo.apply(listener, arguments);
},
stopListening: function() {
var listener = this.state._backboneListener;
return listener.stopListening.apply(listener, arguments);
}
};
(Anything you this.listenTo() will be automatically unsubscribed when your component is unmounted - often this means there's no need to manually call this.stopListening())

To use, simply add it to the mixins array

var HomeView = React.createClass({
displayName: "HomeView",
mixins: [BackboneMixin],
getInitialState: function() {
return {
model: new MyBackboneModel(),
randomNumber: this.randomNumber()
};
},
//...
});

Thats it!

Change a backbone model property and everything updates immediately!

Syncing props to state

If your framework passes data to its root view via this.props then we need to ensure that models and collections are copied over to this.state

Since BackboneMixin monitors for changes on this.state only
var BackbonePropsMixin = {
getInitialState: function() {
return _.pick(this.props, this._subscribableObject);
},
componentWillReceiveProps: function(nextProps) {
var changes = {}, k = null, v = null;
for (k in nextProps) {
v = nextProps[k];
if (this.state[k] !== v && this.props._subscribableObject(v)) {
changes[k] = v;
}
}
for (k in this.props) {
v = this.props[k];
if (this.state[k] === v && !nextProps[k] &&
this.props._subscribableObject(v)) {
changes[k] = null;
}
}
return this.setState(changes);
}
_subscribableObject: (o) {
return (o instanceof Backbone.Model) || (o instanceof Backbone.Collection);
}
};

Don't over-subscribe!

Only use BackboneMixin where you have models on your this.state that you'd like to listen to changes to...

... and only use BackbonePropsMixin on top level components

If you subscribe to the same model in multiple places then React will be forced to redundantly render branches of the view tree multiple times due to the use of this.forceRender()

Re-cap

Don't need to tear everything up and start again

Policy of all new views in React

BackboneMixin auto-subscribes to events

Use BackbonePropsMixin only if necessary

Easier to maintain code - and less of it!

Thank you!

And last but not least... we're hiring!

timecounts.org/jobs

Timecounts: a platform for building and managing communities

Paused

Help

Keyboard shortcuts

, , Pg Up, k Go to previous slide
, , Pg Dn, Space, j Go to next slide
Home Go to first slide
End Go to last slide
b / m / f Toggle blackout / mirrored / fullscreen mode
c Clone slideshow
p Toggle presenter mode
w Pause/Resume the presentation
t Restart the presentation timer
?, h Toggle this help
Esc Back to slideshow