Search code examples
backbone.jssingle-page-applicationbackbone.paginator

Backbone.Collection.reset() => child view is out of sync with parent


I have a list of items. They are stored in backbone pageable collection.

They are displayed like this

|---item1---------------------------|
|---item2---------------------------|
|---item3---------------------------|
|---item4---------------------------|
|---item5---------------------------|
|---item6---------------------------|
|---item7---------------------------|
<< 1,2,3...end >>

User can click on individual item to open detail view in a separate page. Detail view has listeners initialized when it's created. Those listeners are bound to the item model.

Since the detail view is huge, I cache it in the DOM by toggling the visibility. The subsequent click on the item will toggle the cached view.

------ here is the problem -----

When item list is switched to another page, the collection is reset (by paginator). And all the models previously stored in the collection is dereferenced and a new set of models is created. So after the page is switched back and forth, the previously opened item has a different copy of itself stored in the collection. So when I change the name of the item in the detail view (in the view cache), the name in the item list is not changed.

The views are out of sync! because they are referencing to different models.

Not sure if anyone else encounter this before. If you do, please share with me how you solve it.

Thanks very much.


Solution

  • The most straight-forward way to maintain a fresh reference between your list view items and the corresponding detail view, on page change, is to re-render the detail view. But I'm assuming this options is not acceptable within the scope of your project.

    What I often do, when I have the task of forming relationships within logically separate views is use listeners. As long as the views share a unique identifier (for example, they both share a model, or at least identical model ids), I can always send a message that will reach the view I'm interested in.

    For this you'll need a centralized event hub, which with Backbone is trivially easy to generate. In some appropiately global variable (like, for example, MyApp) we simply do:

    MyApp.EventBus = _.extend({}, Backbone.Events);
    

    Set up the detail view

    On the detail view initialize function I would drop this listener,

    initialize: function () {
      // Listen to a toggle visibility on this view
      this.listenTo(MyApp.EventBus, 'detail-view:toggle-view', toggleView);
    },
    
    toggleView: function (id) {
      if (this.model.id == id) {
         // Show this view if I have the passed id
         this.$el.show()
         // Notify the parent list item view that its detail view exists
         MyApp.EventBus.trigger('detail:view:exists', true);
      } else {
        // Hide all other views
        this.$el.hide();
      }
    },
    
    changeName: function () {
      // logic that parses DOM user input to 
      // local variable name
    
      // We now trigger an event 'detail-view:change:name', and we send as 
      // parameters our model's id and the new name
      MyApp.EventBus.trigger('detail-view:change:name', this.model.id, name);
    }
    

    Setting up the list item view

    The list item view will want to listen to a name change (or any other model property in the detail view that you want the list item to be aware of). So we'll set up a handler for the 'detail-view:change:name' event.

    We'll also want to wire our click handler to toggle the visibility of the list item's detail view. The tricky part is to handle the event that a view has not been rendered yet (I'm assuming you're lazy loading the detail view). So we set up a second listener for the detail:view:exists event the detail view triggers when it catches a detail-view:toggle-view event. If we don't hear the detail:view:exists event from the targeted detail view in a timely manner (I'm using 100 ms, but you can play around with that to suit your needs), then we render the view.

    initialize: function () {
      // Listen to when the detail associated with this list item changes
      // the the list item name
      this.listenTo(MyApp.EventBus, 'detail-view:change:name', onNameChange);
      // Set a property in this view if its detail view exists
      this.listenTo(MyApp.EventBus, 'detail:view:exists', 
        _.bind(function () { this.detailViewExists = true; }, this));
      // Create a debounced function that tests whether this view's
      // detail view exists
      _.debounce(_.bind(this.handleViewState, this), 100);
    },
    
    events {
      click: 'toggleDetailView'
    },
    
    toggleDetailView: function (id) {
      MyApp.EventBus.trigger('detail-view:toggle-view', this.model.id);
      this.handleViewState();
    },
    
    // Debounced function that will wait 100ms asynchronously for the 
    // detail view to respond. If the detailViewExists bit is not set to true 
    // then we assume the view does not exist and we render it
    handleViewState: function () {
      if (!this.detailViewExists) 
         // The view does not exist, render and attach the view
    
      // Set the bit to false to allow testing in the event that the detail view
      // is destroyed in the future
      this.detailViewExists = false;
    },
    
    changeName: function (id, newname) {
      if (this.model.id == id) {
         // Change the name of this list item view
         this.$('.item-name').text(newname);
    }
    

    The take-away

    Now, the reference between these two disparate views is the shared unique identifier. Since, by design, these two identifiers are unique in their scope, and should not change, and assuming the detail view has been rendered and attached to the DOM, then regardless of the rendering its state the list item view will always be able to communicate with its detail view.