I'm using Backbone for a personal project in which I created a model named MyModel
. On initialization of this model, I want to populate its attributes from a JSON response from a third-party API:
app.MyModel = Backbone.Model.extend({
url: 'https://api.xxxxxx.com/v12_1/item?id=53444d0d7ba4ca15456f5690&appId=xxxx&appKey=yyyy',
defaults: {
name: 'Default Name'
}
});
This model is used in a collection that will be used in an attribute embedded in a another model:
app.MyModels = Backbone.Collection.extend({
model: app.MyModel
});
app.MyModel2 = Backbone.Model.extend({
// Default attributes
defaults: {
name: 'Default Name'
},
initialize: function() {
this.myModels = new app.MyModels();
this.myModels.on('change', this.save);
}
});
In a view created for MyModel2
, I added a listener to a global element so we can initialize and add instances of MyModel
to MyModels
inside MyModel2
.
app.MyModel2View = Backbone.View.extend({
initialize: function() {
// ...some code...
var self = this;
this.$(".add-myModel").click(function() {
var myModel = new app.MyModel();
myModel.fetch();
self.model.myModels.add(myModel);
});
// ...some code...
},
// ...some code...
});
This is actually doing the intended goal, but throws an error in the console when the element is clicked and the instance added:
backbone.js:646 Uncaught TypeError: this.isNew is not a function
Is this a correct approach in Backbone to populate a model instance from an external API? I'm trying to figure out the reason for this error.
While Stephen is right, he only focuses on the most probable bug and leaves you dealing with everything else. I'll try to expand on this in my answer.
The API's URL is quite complex and it's cumbersome to copy-paste it every time you need it. It's best to get the URL handling in one place and one way to achieve this is with a simple service.
// The API service to use everywhere you need the API specific data.
app.API = {
protocol: 'https',
domain: 'api.xxxxxx.com',
root: '/v12_1/',
params: {
appId: 'xxxx',
appKey: 'yyyy',
},
/**
* Get the full API url, with your optional path.
* @param {String} path (optional) to add to the url.
* @return {String} full API url with protocol, domain, root.
*/
url: function(path) {
path = path || '';
if (path.slice(-1) !== '/') path += '/';
return this.protocol + "://" + this.domain + this.root + path;
},
/**
* Adds the query string to the url, merged with the default API parameters.
* @param {String} url (optional) before the query string
* @param {Object} params to transform into a query string
* @return {String} e.g.: "your-url?param=value&otherparam=123"
*/
applyParams: function(url, params) {
return (url || "") + "?" + $.param(_.extend({}, this.params, params));
},
};
Fill it with the API information.
Then, you can create a base model and collection (or replace the default Backbone behavior).
app.BaseModel = Backbone.Model.extend({
setId: function(id, options) {
return this.set(this.idAttribute, id, options);
},
url: function() {
var base =
_.result(this, 'urlRoot') ||
_.result(this.collection, 'url') ||
urlError();
var id = this.get(this.idAttribute);
return app.API.applyParams(base, this.isNew() || { id: encodeURIComponent(id) });
},
});
app.BaseCollection = Backbone.Collection.extend({
model: app.BaseModel,
sync: function(method, collection, options) {
var url = options.url || _.result(model, 'url') || urlError();
options.url = aop.API.applyParams(url);
return app.BaseCollection.__super__.sync.apply(this, arguments);
}
});
Then using it is as simple as this:
app.MyModel = app.BaseModel.extend({
urlRoot: app.API.url('item'),
})
app.Collection = app.BaseCollection.extend({
model: app.MyModel,
url: app.API.url('collection-items'),
});
The below test outputs:
var app = app || {};
(function() {
app.API = {
protocol: 'https',
domain: 'api.xxxxxx.com',
root: '/v12_1/',
params: {
appId: 'xxxx',
appKey: 'yyyy',
},
/**
* Get the full API url, with your optional path.
* @param {String} path (optional) to add to the url.
* @return {String} full API url with protocol, domain, root.
*/
url: function(path) {
path = path || '';
if (path.slice(-1) !== '/') path += '/';
return this.protocol + "://" + this.domain + this.root + path;
},
/**
* Adds the query string to the url, merged with the default API parameters.
* @param {String} url (optional) before the query string
* @param {Object} params to transform into a query string
* @return {String} e.g.: "your-url?param=value&otherparam=123"
*/
applyParams: function(url, params) {
return (url || "") + "?" + $.param(_.extend({}, this.params, params));
},
};
app.BaseModel = Backbone.Model.extend({
setId: function(id, options) {
return this.set(this.idAttribute, id, options);
},
url: function() {
var base =
_.result(this, 'urlRoot') ||
_.result(this.collection, 'url') ||
urlError();
var id = this.get(this.idAttribute);
return app.API.applyParams(base, this.isNew() || {
id: encodeURIComponent(id)
});
},
});
app.BaseCollection = Backbone.Collection.extend({
model: app.BaseModel,
sync: function(method, collection, options) {
var url = options.url || _.result(model, 'url') || urlError();
options.url = aop.API.applyParams(url);
return app.BaseCollection.__super__.sync.apply(this, arguments);
}
});
app.MyModel = app.BaseModel.extend({
urlRoot: app.API.url('item'),
})
app.Collection = app.BaseCollection.extend({
model: app.MyModel,
url: app.API.url('collection-items'),
});
var model = new app.MyModel();
console.log("New model url:", model.url());
model.setId("53444d0d7ba4ca15456f5690");
console.log("Existing model url:", model.url());
var collection = new app.Collection();
console.log("collection url:", _.result(collection, 'url'));
var modelUrlThroughCollection = new app.BaseModel({
id: "test1234"
});
collection.add(modelUrlThroughCollection);
console.log("model via collection:", modelUrlThroughCollection.url());
})();
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.3.3/backbone-min.js"></script>
New model url: https://api.xxxxxx.com/v12_1/item/?appId=xxxx&appKey=yyyy
Existing model url: https://api.xxxxxx.com/v12_1/item/?appId=xxxx&appKey=yyyy&id=53444d0d7ba4ca15456f5690
collection url: https://api.xxxxxx.com/v12_1/collection-items/
model via collection: https://api.xxxxxx.com/v12_1/collection-items/?appId=xxxx&appKey=yyyy&id=test1234
Backbone.js gives structure to web applications by providing models with key-value binding and custom events, collections with a rich API of enumerable functions, views with declarative event handling, and connects it all to your existing API over a RESTful JSON interface.
If the API you're using adheres to REST principles, there's probably an endpoint which returns an array of objects. This is where the collection should fetch its data.
app.Collection = app.BaseCollection.extend({
model: app.MyModel,
url: app.API.url('collection-items'),
});
var collection = new app.Collection();
// GET request to
// https://api.xxxxxx.com/v12_1/collection-items/?appId=xxxx&appKey=yyyy
collection.fetch();
And it should receive something like:
[
{ id: "24b6463n5", /* ... */ },
{ id: "345333bbv", /* ... */ },
{ id: "3g6g346g4", /* ... */ },
/* ... */
]
If you want to add an existing model (referenced with an ID) to a collection:
var model = new app.MyModel({
// giving an id to a model will make call to fetch possible
id: "53444d0d7ba4ca15456f5690"
});
// GET request to
// https://api.xxxxxx.com/v12_1/item/?appId=xxxx&appKey=yyyy&id=53444d0d7ba4ca15456f5690
model.fetch();
collection.add(model);
The response should be a single object:
{ id: "53444d0d7ba4ca15456f5690", /* ... */ }
If you want to create a new model:
var model = new app.MyModel({ test: "data", /* notice no id passed */ });
// POST request to
// https://api.xxxxxx.com/v12_1/item/?appId=xxxx&appKey=yyyy
model.save();
// or, equivalent using a collection:
collection.create({ test: "data", /* notice no id passed */ });
.on
/.bind
in favor of .listenTo
Passing the context on an event binding is important with Backbone as most parts are classes versus jQuery callbacks which are usually anonymous functions working on local variables. In addition to this, you should use Backbone's listenTo
instead of on
.
listenTo
is the newer and better option because these listeners will be automatically removed for you duringstopListening
which is called when a view gets removed (viaremove()
). Prior tolistenTo
there was a really insidious problem with phantom views hanging around forever (leaking memory and causing misbehavior)...
In the views, you should use the events
property to automatically delegates the DOM events to the view's callbacks. It's still jQuery in the background, but cleaner, already integrated into Backbone and the context is automatically passed, so there's no need to use the var self = this
trick.
app.MyModel2View = Backbone.View.extend({
events: {
"click .add-myModel": "onAddModelClick",
},
onAddModelClick: function() {
this.model.myModels.add({});
},
// ...some code...
});
Creating a new model and fetching it makes no sense from the Backbone design unless you pass an id to the model. Just calling add
on the collection with an empty object will create a default model.