Dealing with Complex Persisted Data
If you are developing a Backbone / Marionette application, at some point you will very likely have to perform the following core operations:
- Fetch data from the server.
- Update your models with the server data.
- Render the updated models.
The complexity and difficulty of this process largely depends on how closely the format of your server data matches that of your view(s). If it's a perfect match, then the process is relatively straightforward -- you instantiate a collection with the appropriate URL property, call Backbone's fetch
method on the collection, and pass that collection to your view of choice, e.g.
Server response. Nicely formatted in a "JSON array of models," just like Backbone expects.
[
{
name: 'John Smith',
age: 30,
city: 'Los Angeles',
date: 07/15/2014
},
{
name: 'Bill Johnson',
age: 33,
city: 'San Francisco',
date: 07/05/2014
},
{
name: 'Ann Doe',
age: 35,
city: 'Boulder',
date: 07/01/2014
},
]
Collection, view, model, and templates.
MyModel = Backbone.Model.extend({
urlRoot : '/api/data'
});
MyCollection = Backbone.Collection.extend({
url : '/api/data',
model : MyModel
});
MyItemView = Marionette.ItemView.extend({
template : itemTemplate
});
MyCompositeView = Marionette.CompositeView.extend({
template : compositeTemplate,
className : 'table-content'
itemView : MyItemView
});
// compositeTemplate (written in Jade).
div.header-cell NAME
div.header-cell AGE
div.header-cell CITY
div.header-cell DATE
// itemTemplate
div.body-cell #{name}
div.body-cell #{age}
div.body-cell #{city}
div.body-cell #{date}
Code to get the server data and display it in your view.
myCollection = new MyCollection();
myCollection.fetch();
myCompositeView = new MyCompositeView({ collection: myCollection });
// app is a Marionette application. myRegion is a Marionette region.
app.myRegion.show(myCompositeView);
However, if your server data is a far cry from what Backbone and/or Marionette expects, or from what you'd like to show end users in your views, then the process becomes a bit more nuanced. For example, suppose that instead of getting a nice array of models with clean attributes in your response, your get something that looks more like this:
Server response.
{
data:
[
{
name: 'John Smith',
age: 30,
address: {
city: 'Los Angeles',
state: 'CA'
},
date: 1318781876
},
{
name: 'Bill Johnson',
age: 33,
address: {
city: 'San Francisco',
state: 'CA'
},
date: 1318781876
},
{
name: 'Ann Doe',
age: 35,
address: {
city: 'Boulder',
state: 'CO'
},
date: 1318781876
},
],
someOtherKey: 'someOtherValue'
}
Yikes. Your array of models is now buried in an object under a "data" key, your "city" attribute is nested inside an "address" object, and your date is a Unix timestamp instead of a nicely formated "MM/DD/YY" string! Given this data format, simply calling fetch
won't work (if you do, you'll end up with only one "model" that has a "data" attribute and a blank view). Instead, we need to use a few helper methods, Backbone's parse
and Marionette's serializeData
, to get our data where we want it to be.
Let's start with Backbone's parse
. Per the documentation, Backbone's parse
function is "called by Backbone whenever a collection's models are returned by the server in fetch
." It takes the server response as an argument, and by default just passes it on through. Now this isn't very helpful on its own, but we can override parse
to meet our needs. In this case, we should modify parse
like so:
MyCollection = Backbone.Collection.extend({
// Custom parse function.
parse : function(response, options) {
return response.data;
}
});
Here, we take the server response object, and return the value of the "data" key, which is array of models. Backbone can now set
the models properly.
But our work isn't finished. We still need to deal with the embedded "city" attribute and the "date" attribute. For this task, Marionette's serializeData
is ideally suited. serializeData
is similar to parse
in that the default function is essentially a no-op that you need to override (technically it's not a no-op -- it performs a shallow clone of the models' attributes via Backbone's toJSON
but that's effectively just passing the attributes as-is on to the view / template). It takes no arguments, and returns an object with the attributes formatted for display in your view. This is key -- serializeData
doesn't actually affect your model attributes themselves, it just packages them up for presentation, which is what we want. Our serializeData
function might look something like the following:
Our ItemView with the new serializeData
function
MyItemView = Marionette.ItemView.extend({
template : itemTemplate
serializeData : function() {
// Get the relevant model attributes.
var addressObject = this.model.get('address');
var unixDate = this.model.get('date');
// Return an object for your template.
return {
name : this.model.get('name'),
age : this.model.get('age'),
city : addressObject.city,
// using moment.js library to format the date.
date : moment.unix(unixDate).format('MM/DD/YYYY')
};
}
});
Nothing needs to change in our ItemView template
// itemTemplate
div.body-cell #{name}
div.body-cell #{age}
div.body-cell #{city}
div.body-cell #{date}
Now you might be saying to yourself, "parse
and serializeData
seem to be doing similar things...what's the difference? When should I use parse
and when should I use serializeData
?" Good question. You actually could have used parse
to accomplish everything we did in the above example -- access the relevant JSON array of models and clean up the city and date attributes. But there's a good reason why we didn't do it that way: separation of concerns. parse
affects your data structure -- whatever you return from parse
will be used to set your model attributes. Using parse
to clean up the "city" and "date" attributes would therefore be modifying your data structure to fit your view. Not only does this create a tight coupling, but it also creates a disparity between the models on the server and the models on the client, which in turn makes syncing between client and server all the more difficult. Not good. That's why we used serializeData
to clean up the city and date attributes. seralizeData
does not affect the models themselves, it only affects how the models' attributes are prepared for the view. Make as many adjustments as you wish, your data structure remains intact.
So in summary:
- Use Backbone's
collection.parse
andmodel.parse
to translate server responses into proper data structures for Backbone (objects for models and arrays of objects for collections). - Use Marionette's
ItemView.serializeData
to modify Backbone model attributes for display in your views.
Two additional things to keep in mind. First, parse
is both a model and a collection method and if you have parse defined for your collection and your model both parse
methods will be called anytime you return a collection of models from the server. Chances are you don't want both model.parse
and collection.parse
to be called whenever you get a collection back from the server, so you need to address this. Fortunately, whenever a model is created as part of a collection, Backbone passes a collection option to the model constructor, which you can check for and accommodate accordingly:
// Model parse function.
parse : function(response, options) {
// If model is part of a collection, just pass the response on through.
if (options.collection) {
return response;
}
// If model is on its own, execute your parse logic.
else {
return response.data
}
}
Full example can be found in this stackoverflow answer.
Second, even though it's currently the default option in Marionette (there are plans to change it soon), you really shouldn't use toJSON
to serialize data for your views. This is due to the fact that toJSON
is also used to prep data for the server, and chances are you don't want to override your server prep methods with your view prep methods. Does that mean you have to write a serializeData
function for every single view to keep things straight? Fortunately not. Just override Marionette's serializeModel
and serializeCollection
methods:
Marionette.View.prototype.serializeModel = function(model) {
model = model || this.model;
return _.clone(model.attributes);
};
Marionette.ItemView.prototype.serializeCollection = function() {
return collection.map(function(model) {
return this.serializeModel(model);
}, this);
};
That's it for now. Happy coding!