Simple Model-View synchronization with dstore and Dijit

By on July 28, 2015 7:14 am

dstore

Nearly every sufficiently large web application looks for a mechanism to efficiently synchronize or bind data between the Model and the View. There are many large scale application frameworks and approaches focused on this, whether the binding is one-directional like React, or follows other approaches such as those seen with AmpersandJS, Angular, Aurelia, Backbone, Ember, Knockout, Mayhem, or many others listed on ToDoMVC.

Simple Model-View synchronization

Many of our customers use Dojo and Dijit, because it’s a comprehensive toolkit for building web applications that work today, and while it does not intend to be an MV* framework, it already includes a lightweight approach to getters and setters.

We are sometimes asked to provide a general-purpose mechanism for creating view components that are only driven by the model, with data binding semantics build on top.

Some users will include Knockout or Angular or React with Dojo, while others might even just look to dgrid if they wanted to build something simple like ToDoMVC.

But what is a good general approach that would work with the things included in Dojo today? The basic approach would be to create a base mixin that includes templating, attribute setting, and a method to specify how items are rendered.

The benefits of this approach are that you can avoid the perils of trying to manually synchronize DOM state, widget properties, and model state. It also makes it so the user does not need to worry about removing or updating items in the DOM.

Simple usage

Ideally, we’ll end up with a pattern that can be used like this in simple cases:

define([
	'dojo/_base/declare',
	'dojo/dom-construct',
	'dojo/on',
	'dojo/_base/lang',
	'dijit/_WidgetBase',
	'custom/store/_DstoreDijitMixin',
	'dojo/query'
], function (declare, domConstruct, on, lang, _WidgetBase, _DstoreDijitMixin) {
	return declare([_WidgetBase, _DstoreDijitMixin], {
		postCreate: function () {
			this.inherited(arguments);
			on(this, 'div:click', lang.hitch(this,
						'removeItemFromStore'));
		},
		removeItemFromStore: function (event) {
			var id = parseFloat(event.target.id, 10);
			this.store.remove(id);
		},
		_renderItem: function (item, index) {
			return domConstruct.create('div', {
				textContent: item.text,
				id: item.id
			});
		}
	});
});

A full example in action is available, as well as a GitHub example repo that you can clone for your own use. Or continue reading if you would like to learn how this works.

_DstoreDijitMixin

Here we’ll cover a more in-depth approach for applying this pattern with the more recent dstore package for solving this challenge of efficient synchronization and data binding. If you are not familiar with dstore, you may want to start with our dstore tutorial series.

We will create a custom module, custom/store/_DstoreDijitMixin, to allow Dijit widgets (View) to be driven by a dstore (Model).

First we begin with an AMD block, including Dojo’s mechanisms for creating mixins and simple DOM manipulation:

define([
    'dojo/_base/declare',
    'dojo/dom-construct'
], function (declare, domConstruct) {

Then, we will add a couple of helper utilities. First, we will make sure that a dstore event is valid by checking to see if the store contains a track method, and making sure that both the previousIndex and index properties are defined. Otherwise the event will be ignored as it is presumably from a portion of the underlying data set that has been filtered out of the current collection of data.

    function isValidEvent(store, event) {
        return !('track' in store) ||
            typeof event.previousIndex !== 'undefined' ||
            typeof event.index !== 'undefined';
    }

Then we will add a utility to make sure that the item is a widget:

    function isWidget(item) {
        // The existence of a `postCreate` method on the item
        // will indicate that it's a widget
        return typeof item.postCreate === 'function';
    }

Then we will start the definition of the mixin itself, first by setting some default values to make it easier to inject dependencies such as a reference to the dstore instance in the constructor of the widgets including this mixin:

    return declare(null, {
        store: null,
        _storeEvents: null,
        _viewsByItem: null,

We will then create a custom setter method for the widget instance, to override the setting of the store. In this method, we will check to see if the store has a track method, and then call store.track() and use its return value as `this.store`. Calling track here allows outside code to pass filtered collections of the original store other other store-backed widgets.

        _setStoreAttr: function (store) {
            this._set('store', (store && store.track ? store.track() : store));

            if (this._storeEvents) {
                // Remove all event listeners from the old store so that
                // changes to it do not continue to update this widget.
                this._storeEvents.forEach(function (handle) {
                    handle.remove();
                });
                this._storeEvents = null;
            }

            if (store) {
                this._registerStoreEvents();
                this._render();
            }
        },

Note that _registerStoreEvents is a method we will create later for managing dstore’s add, update, and delete events.

In our constructor, we’ll create a new array that simply lists the views by item:

        constructor: function () {
            this._viewsByItem = [];
        },

We then can add a simple method for rendering a store item and injecting the resulting node/widget into this widget:

        insertPosition: 'last',

        _insertItem: function (item, index) {
            var renderedItem = this._renderItem(item, index);
            var nextItem = this._viewsByItem[index];
            var position = nextItem ? 'before' : this.insertPosition;

            if (isWidget(renderedItem)) {
                renderedItem.placeAt(nextItem || this, position);
            }
            else {
                domConstruct.place(
                    renderedItem,
                    nextItem || this.containerNode || this.domNode,
                    position
                );
            }

            this._viewsByItem.splice(index, 0, renderedItem);
        },

We’ll then create a mechanism to listen for dstore’s add, update and delete events and handle them to either create a new item, update an existing item, or remove an item, respectively:

        _registerStoreEvents: function () {
            // summary:
            //        Add events listeners to the store to handle view changes.

            var store = this.store;

            if (store) {
                var self = this;
                this._storeEvents = [];
                this._storeEvents.push(store.on('add', function (event) {
                    if (isValidEvent(store, event)) {
                        self._insertItem(event.target, event.index);
                    }
                }));
                this._storeEvents.push(store.on('update', function (event) {
                    if (isValidEvent(store, event)) {
                        self._destroyItem(event.previousIndex);
                        self._insertItem(event.target, event.index);
                    }
                }));
                this._storeEvents.push(store.on('delete', function (event) {
                    if (isValidEvent(store, event)) {
                        self._destroyItem(event.previousIndex);
                    }
                }));
            }
        },

We then define a method to kick off the render process, and a function to kick off the rendering of each item in the store:

        _render: function () {
            // summary:
            //        Render the widget contents

            // destroy any existing items
            this._destroyItems();
            this._renderItems();
        },

        _renderItems: function () {
            // summary:
            //        Render store results

            this.store && this.store.forEach(this._insertItem, this);
        },

And then we define a general method for rendering. The beauty of this approach is that each widget can then simply subclass this mixin with its own mechanism for items should be rendered! It receives two arguments, the item to rendering, and optionally an index of the item in the collection:

        _renderItem: function (item, index) {
            // returns: DomNode|dijit/_WidgetBase
        },

We then implement a mechanism to cleanly destroy an item, with a reference to its index before it is destroyed. This method will be called when the dstore is updated to remove an item from the store, and then tracked into the view:

        _destroyItem: function (/*number*/previousIndex) {

            var renderedItem = this._viewsByItem.splice(previousIndex, 1)[0];

            if (renderedItem) {
                // Since Dijit does not provide an `isWidget` method,
                // we rely on duck typing...
                if (isWidget(renderedItem)) {
                    renderedItem.destroyRecursive();
                } else {
                    domConstruct.destroy(renderedItem);
                }
            }
        },

And finally we have a mechanism to destroy all items within a widget instance. Note that destroyDescendants is a method provided by Dijit that, as you can guess from its name, will remove all descendants of a widget:

        _destroyItems: function () {
            // summary:
            //        Destroy currently rendered items

            this._viewsByItem = [];

            // preserve the item DOM while destroying widgets
            // so we can remove all item DOM nodes in one shot
            var preserveDom = true;
            this.destroyDescendants(preserveDom);

            domConstruct.empty(this.containerNode || this.domNode);
        }
    });
});

Then, to put this to use, we’ll simply include the mixin in our various widget definitions, and then override with specific renderItem methods, and instantiate a Trackable dstore instance. A full example in action is available, as well as a GitHub example repo that you can clone for your own use.

Learning more

SitePen covers architecture and advanced Dijit and store usage, and much more in our Dojo workshops offered throughout the US, Canada, and Europe, or at your location. We also provide expert Enterprise JavaScript and Dojo support and development services. Contact us for a free 30 minute consultation to discuss how we can help you and your organization achieve more with JavaScript!