One of the most important parts of a web application is its ability to persist data. Without this persistence, every page reload would start the application over from scratch and, well, that's not very interesting. Backbone handles persistence agnostically through its sync
method. This gives developers the flexibility to use AJAX, localStorage, web sockets, and/or any other data transfer protocol by simply overriding sync
. This idea of encapsulating the data transfer method while not altering any of Backbone's core Model
or Collection
methods makes setting up client-to-server communication a breeze. Here is a figure representing this:
It's a good idea get a solid understanding of the sync
algorithm before trying to override it. Failure to do so will leave your models and collections hanging high and dry in the data department. The best way to get a grasp of how sync
works is to browse through the annotated source code, but we'll go over the major points you'll need to remember when writing your own sync
method. After the basics, we can dive into Hubbub's sync
method that uses both AJAX and localStorage for persistence.
The first thing to note about sync
is its signature: (method, model, options)
. These three arguments will give you all you need to create a request for the server. The first argument, method
, will always be a CRUD string ('create'
, 'read'
, 'update'
, or 'delete'
). 'patch'
was added in Backbone 0.9.9
, but browser and server support for 'patch'
is still a bit lacking, so we'll focus on the main four. The second argument, model
, can be a bit misleading, but it will either be an instance of a Model
(as the variable name implies) or an instance of a Collection
. model
is the instance we're going to be creating, reading, updating, or removing. The final argument is options
. As with most Backbone methods, an options
object is provided to pass along helpful information for tailoring your application. Common options
for sync
are success
and error
, which are callbacks that need to be invoked accordingly, if they exist.
The next important piece of sync
is data serialization. This is the process of taking a model
(or collection
) with all of its attributes and methods and turning it into something the server can understand. The default method of serialization in Backbone is with JSON (JavaScript Object Notation). Calling a model
's toJSON
method will extract this server-ready JSON. For simple models, the default toJSON
will work just fine. For more complex attribute structures like those with nested models and collections, toJSON
will most likely need to be overridden to format the data to the server's liking.
Once the serialized data is attained, it's time to act on the CRUD method
. This part of sync
will be where most of the customization is necessary as it is where the actual server request will be sent. This example will use AJAX because it is currently the most common.
AJAX requests are composed of a few key properties, namely the method, url, data, and callbacks. Fortunately, the CRUD methods map very well to HTTP's request methods:
The url for the request is also simple with Backbone. It can be attained by using the Underscore result
method and applying it to the passed in model
. result
takes an object and a property string and returns the value of the property or, if the property is a function, the value of the function. For our case, this would look like:
var url = _.result(model, 'url');
With the method and url set, the AJAX request is already half-way complete.
The next thing we need is the request data, which just so happens to be the same data we serialized earlier with toJSON
. Piece of cake.
The final step is to bind the options.success
and options.error
callbacks to the request and send it off. Backbone sets the user's AJAX library of choice to Backbone.ajax
, so all you have to do is invoke Backbone.ajax(options)
.
The default sync
implementation also triggers a few events on the passed in model
:
'request'
- when the request is sent'sync'
- when the request succeeds'error'
- when the request failsThese events can be helpful for telling views when to show or hide loading spinners, for example.
Now that the basics of sync
are covered, we can look at how Hubbub overrides sync
to work for GitHub's API through AJAX as well as for the browser's localStorage
. You'll want to take a look at the sync.js file in the root of the Hubbub repository as you read this section.
The first decision we had to make was how to distinguish AJAX requests from localStorage. Initially, it was tempting to store a flag (something like 'localStorage: true') at the model level, but that quickly became a convenience that wouldn't work. The reason is that we wanted to be able to use one model to fetch
from both AJAX and localStorage
endpoints, not just one persistence method per model.
The goal was to be able to fetch
a repository or issue from GitHub, then save
it locally, and then fetch
it locally. It was especially important that we be able to cache results locally due to GitHub's relatively low API request limit of sixty per hour. The solution was to use sync
's handy options
argument. We decided to pass remote
flag and use that to branch our sync
logic into two paths: a standard AJAX request with the default sync
method when remote
is true
, and a customized localStorage request when it's not.
The following sections give a line-by-line explanation of our custom sync
method.
Obviously rewriting the default sync
method for use in our AJAX case would be a pain, so we store the default sync
in a local variable in our closure.
var sync = Backbone.sync;
By doing this, we can reassign Backbone.sync
and still reuse the default sync
Backbone provides for AJAX. Inside our sync
function, this is the conditional we use to make the AJAX or localStorage decision.
if (method === 'read' && options.remote) {
options.data = _.extend({per_page: 100}, options.data);
return sync.apply(this, arguments);
}
You can see that in the case of a 'read'
method when the options.remote
flag is truthy, we will set our default per_page
option for GitHub and proxy the arguments to the original sync
we stored earlier. You already know that the AJAX portion of our sync
method works because it's the same as the original Backbone.sync
, so now we'll focus on at the localStorage
side of it.
localStorage
is essentially one big key-value store, saved in the client's browser, that persists across browsing sessions. It can't directly store complex JavaScript objects, but it can store strings. Fortunately our awesome serialization tactics (model.toJSON()
and JSON.stringify(data)
) have already given us the ability to turn complex JavaScript objects into simple strings. We'll look at this in more detail later in the function.
It's important to note that some older browsers do not support localStorage
, but there are polyfills out there that can solve that problem. We didn't bother polluting Hubbub with extra code for the sake of a terser sample project (sorry old browsers). Overall localStorage
is a great medium for storing data that doesn't need to persist on a server somewhere. It can hold more data than cookies and also doesn't come with the added overhead on every HTTP request.
window.localStorage
is kind of a long variable name to type, so we store this behemoth as ls
. res
will hold the result we send to the options.success
callback.
var ls = window.localStorage
var res;
In an effort to reuse as many resources as we could for both the GitHub and localStorage API, we chose to use each model's urlRoot
and a key. This provides a unique location for data to be stored for each type of model (user, repo, issue, etc.).
var url = _.result(model, 'urlRoot') || _.result(model, 'url');
The first thing to do before any CRUD operations can take place is to retrieve what already exists in localStorage. Here we get the string from localStorage and parse it into a real JavaScript object.
var models = ls.getItem(url);
models = models ? JSON.parse(models) : {};
The next step is to act on the passed in method
. The easiest way to branch logic based on a single value is through a switch
statement, as you see here.
switch (method) {
The first case we handle is 'create'
and 'update'
. Since these two methods will take almost identical actions, we group them together.
case 'create':
case 'update':
First we set the response to the result of the model's serialization method, toJSON
. In general, our response will be what the server sends back, but in this case there is no server and we already know what the response should be.
res = model.toJSON(options);
This next bit only applies to the create
case. In Backbone, the 'create'
method is used when a model returns true
for its isNew
method. Unless you override it, isNew
will simply check the existance of the model's id
. The logic being if it has an id
, it must exist and therefore we're 'update'
-ing it, otherwise we must 'create'
it.
Normally an id
is assigned to a model
through that model
's corresponding record in the database. Since we don't have a remote database, it's our job to assign the unique id
manually. To do this, we simply start by assuming an available id (stored in our available
variable) of 1
. We then iterate through the existing models
and increase our available
ID if it has already been taken.
Note the unary +
operator placed before the id
in the assignment. This operator is handy for converting non-numbers to numbers. Since the keys of our localStorage
data are stored as strings, we want to convert them to numbers before assigning them as an id
. Comparing numbers as strings would yield undesirable results, for example '2' > '11'
is true
, but 2 > 11
is false
. Finally, assign the res
ID to the available
ID.
if (method === 'create') {
var available = 1;
for (var id in models) if (available <= id) available = +id + 1;
res.id = available;
}
The last thing we need to do before saving is update the models
object with our model
's data.
models[res.id] = res;
Now we override the old models
with the modifed models
using localStorage.setItem
. Remember to JSON.stringify
the data that's being saved, otherwise it is will be stored by the data's toString
method. Try saving an empty object into localStorage
and you'll see something like '[object Object]'
is really what is stored.
ls.setItem(url, JSON.stringify(models));
break;
'read'
is the next case we handle, and it's probably the simplest. All we have to do in this case is make sure we return the right response. When the model
passed to sync
is actually a Model
, we return the model with the requested model's id, or if it doesn't exists, an empty object. If the model
isn't a Model
, it must be a Collection
, so return an array of all the models
object values using Underscore's values
method.
case 'read':
res =
model instanceof Backbone.Model ?
models[model.id] || {} :
_.values(models);
break;
The final CRUD method to handle is 'delete'
. The response data for 'delete'
isn't used because the model
's destroy
method is just looking for a success
or error
callback, so we set it to an empty object. Then delete
the model
from the models
object using its id
as the key, and finally save it to localStorage
with localStorage.setItem
.
case 'delete':
res = {};
delete models[model.id];
ls.setItem(url, JSON.stringify(models));
}
The very last step in our sync
function is to invoke the options.success
or options.error
callback. In our case, there is no possibility for an error like connection interruption or unauthorized use, so we'll always invoke options.success
.
options.success(res);
And that's it! This is just one of the many ways sync
can be leveraged to store data in multiple locations with multiple transports.
To recap, sync
is the data bridge between the browser and persistent storage. The beauty lies in the fact that you can choose whatever persistent storage method you want and then easily tailor Backbone to work seemlessly with it. You should now have a good idea on how you could write your own AJAX, localStorage
, or hybrid sync
method. If you're looking for a challenge, try writing a sync
method that works with web sockets. In the next chapter we will look at URL state and how Backbone.router manages that for us.
There has been error in communication with Booktype server. Not sure right now where is the problem.
You should refresh this page.