marionette.state

One-way state architecture for a Marionette.js app.

Usage no npm install needed!

<script type="module">
  import marionetteState from 'https://cdn.skypack.dev/marionette.state';
</script>

README

marionette.state

One-way state architecture for a Marionette.js app.

Build Status Test Coverage Code Climate Dependency Status

Installation

npm install marionette.state
bower install marionette-state
git clone git://github.com/Squareknot/marionette.state.git

Documentation

Reasoning

A Marionette View is a DOM representation of a Backbone model. When the model updates, so does the view. Here is a quick example:

// Region to attach views
var region = new Mn.Region({ el: '#region' });

// Model synced with '/rest-endpoint'
var model = new Backbone.Model({ url: '/rest-endpoint' });

// View will re-render when the model changes
var View = Mn.ItemView.extend({
  modelEvents: {
    'change': 'render'
  }
});

// Create the view
var view = new View({ model: model });

// Fetch the latest data
model.fetch().done(() => {
  // Show the view with initial data
  region.show(view);
});

// Updating the model later will cause the view to re-render.
model.fetch();

This is great for views that are only interested in representing simple content. Consider more complex, yet quite common, scenarios:

  • A view renders core content but also reacts to user interaction. E.g., a view renders a list of people, but the end user is able to select individal items with a "highlight" effect before saving changes.
  • A view renders core content but also depends on external state. E.g., a view renders a person's profile, but if the profile belongs to the authenticated user then enable "edit" features.
  • Multiple views share a core content model but each have unique view states. E.g., multiple views render a user profile object, but in completely different ways that require unique view states: an avatar beside a comment, a short bio available when hovering over an avatar, a full user profile display.

Common solutions:

  • Store view states in the core content model, but override toJSON to avoid sending those attributes to the server.
  • Store view states in the core content model shared between views, but avoid naming collisions or other confusion (which view is "enabled"?).
  • Store view states directly on the view object and follow each "set" with "if different" statements so you know when a state has changed.

Each of these solutions works up until a point, but side effects mount as complexity rises: Logic-heavy views, views unreliably reflecting state changes, models doing too much leading to excessive re-renders, accidentally transmitting state data to server on save.

Separating state into its own entity and then maintaining that entity with one-way data binding solves each of these problems without the side effects of other solutions. It is a pattern simple enough to implement using pure Marionette code, but this library seeks to simplify the implementation further by providing a state toolset.

Mn.State allows a view to seamlessly depend on any source of state while keeping state logic self-contained and eliminates the temptation to pollute core content models with view-specific state. Best of all, Mn.State does this by providing declarative and expressive tools rather than over-engineering for every use case, much like the Marionette library itself.

Examples

In each of these examples, views are demonstrated without core content models for simplicity. This emphasizes that state management is occurring independently from renderable core content. Adding core content models should be familiar to any Marionette developer.

Stateful View

From time to time, a view needs to support interactions that only affect itself. On refresh, these states are reset. In this example, a transient view spawns it own, also transient, State.

State flow for a simple interactive view:

  1. A view is rendered with some initial state.
  2. The user interacts with the view, triggering a state change.
  3. The view reacts by updating the DOM according to the new state.

Solved with Mn.State:

  1. View renders initial View State.
  2. View triggers events that are handled by View State.
  3. View State reacts to view events, updating its attributes.
  4. View reacts to state changes, updating the DOM.
// Listens to view events and updates view state attributes.
var ToggleState = Mn.State.extend({
  defaultState: {
    active: false
  },

  componentEvents: {
    'toggle': 'onToggle'
  },

  onToggle() {
    var active = this.get('active');
    this.set('active', !active);
  }
});

// A toggle button that is alternately "active" or not.
var ToggleView = Mn.ItemView.extend({
  template: 'Toggle Me',
  tagName: 'button',

  triggers: {
    'click .js-toggle': 'toggle'
  },

  stateEvents: {
    'change:active': 'onChangeActive'
  },

  // Create and sync with my own State.
  initialize() {
    this.state = new ToggleState({ component: this });
    Mn.State.syncEntityEvents(this, this.state, this.stateEvents, 'render');
  },

  // Active class will be added/removed on render and on 'active' change.
  onChangeActive(state, active) {
    if (active) {
      this.$el.addClass('is-active');
    } else {
      this.$el.removeClass('is-active');
    }
  }
});

var toggleView = new ToggleView();

var appRegion = new Mn.Region({ el: '#app-region' });
appRegion.show(toggleView);

View Directly Dependent upon Application State

Relatively often, it is convenient for a view to depend on long-lived application state. This example uses authentication status to demonstrate binding a view directly to the state of the application.

State flow for a simple view that depends directly upon long-lived application state:

  1. A view is rendered with current app state.
  2. The view triggers an app-level event, resulting in an app state change.
  3. The view reacts to app state changes, updating the DOM.

Solved with Mn.State:

  1. View renders initial App State.
  2. View trigger events that are handled by App State.
  3. App State reacts to events, updating its attributes.
  4. View reacts to App State changes, updating the DOM.
// Listens to application level events and updates app State attributes.
var AppState = Mn.State.extend({
  defaultState: {
    authenticated: false
  },

  componentEvents: {
    'login': 'onLogin',
    'logout': 'onLogout'
  },

  onLogin() {
    this.set('authenticated', true);
  },

  onLogout() {
    this.set('authenticated', false);
  }
});

// Alternately a login or logout button depending on app authentication state.
var ToggleAuthView = Mn.ItemView.extend({
  template: 'This Button Label Will Be Replaced',
  tagName: 'button',

  triggers: {
    'click': 'loginLogout'
  },

  appStateEvents: {
    'change:authenticated': 'onChangeAuthenticated'
  },

  // Bind to app State.
  initialize(options={}) {
    this.appState = options.appState;
    this.appChannel = Radio.channel('app');
    Mn.State.syncEntityEvents(this, this.appState, this.appStateEvents, 'render');
  },

  // Button text will be updated on every render and `action` change.
  onChangeAuthenticated(appState, authenticated) {
    if (authenticated) {
      this.$el.text('Logout');
    } else {
      this.$el.text('Login');
    }
  },

  // Login/logout toggle will always fire the appropriate action.
  loginLogout() {
    if (this.appState.get('authenticated')) {
      Radio.trigger('app', 'logout');
    } else {
      Radio.request('app', 'login');
    }
  }
});

var appChannel = Radio.channel('app');
var appState = new AppState({ component: appChannel });
var toggleAuthView = new ToggleAuthView({ appState: appState });

var appRegion = new Mn.Region({ el: '#app-region' });
appRegion.show(toggleAuthView);

View Indirectly Dependent upon Application State

Sometimes a view has its own, transient, internal state that is related to long-lived application state. While this particular example doesn't require that layer of indirection to achieve its goal (a Login/Logout button), the goal here is to demonstrate all that is necessary to achieve two tiers of State.

State flow for a simple view that depends indirectly on long-lived application state:

  1. View is rendered with initial state dependent upon current app state.
  2. View triggers an app-level event, resulting in an app state change.
  3. App state change results in a view state change.
  4. View reacts to view state changes, updating the DOM.

Solved with Mn.State:

  1. View State synchronizes with App State.
  2. View renders initial View State.
  3. View triggers events that are handled by App State.
  4. App State reacts to events, updating its attributes.
  5. View State reacts to App State changes, updating its attributes.
  6. View reacts to View State changes, updating the DOM.
// Listens to application level events and updates state attributes.
var AppState = Mn.State.extend({
  defaultState: {
    authenticated: false
  },

  componentEvents: {
    'login': 'onLogin',
    'logout': 'onLogout'
  },

  onLogin() {
    this.set('authenticated', true);
  },

  onLogout() {
    this.set('authenticated', false);
  }
});

// Syncs with application State.
var ToggleAuthState = Mn.State.extend({
  defaultState: {
    action: 'login'
  },

  appStateEvents: {
    'change:authenticated': 'onChangeAuthenticated'
  },

  initialize(options={}) {
    this.appState = options.appState;
    this.syncEntityEvents(this.appState, this.appStateEvents);
  },

  // Called on initialize and on change app 'authenticated'.
  onChangeAuthenticated(appState, authenticated) {
    if (authenticated) {
      this.set('action', 'logout');
    } else {
      this.set('action', 'login');
    }
  }
});

// Alternately a login or logout button depending on app authentication state.
var ToggleAuthView = Mn.ItemView.extend({
  template: 'This Button Label Will Be Replaced',
  tagName: 'button',

  triggers: {
    'click': 'loginLogout'
  },

  stateEvents: {
    'change:action': 'onChangeAction'
  },

  // Create and bind to my own State, which is injected with app State.
  initialize(options={}) {
    this.appChannel = Radio.channel('app');
    this.state = new ToggleAuthState({
      appState: options.appState,
      component: this
    });
    Mn.State.syncEntityEvents(this, this.state, this.stateEvents, 'render');
  },

  // Button text will be updated on every render and 'action' change.
  onChangeAction(state, action) {
    this.$el.text(action);
  },

  // Login/logout toggle will always fire the appropriate action.
  loginLogout() {
    this.appChannel.trigger(this.state.get('action'));
  }
});

var appChannel = Radio.channel('app');
var appState = new AppState({ component: appChannel });
var toggleAuthView = new ToggleAuthView({ appState: appState });

var appRegion = new Mn.Region({ el: '#app-region' });
appRegion.show(toggleAuthView);

View Indirectly Dependent upon Application State with Business Service

An application with a business layer for handling persistence to a server is just one more step--the addition of an app controller that responds to Radio requests.

State flow for a simple view that depends indirectly on long-lived application state connected to a business service:

  1. View is rendered with initial state dependent upon current app state.
  2. View makes an app-level request, affecting business objects and resulting in an app state change.
  3. App state change results in a view state change.
  4. View reacts to view state changes, updating the DOM.

Solved with Mn.State:

  1. View State synchronizes with App State.
  2. View renders initial View State.
  3. View makes requests that are handled by App Controller.
  4. App Controller modifies business objects and triggers app events.
  5. App State reacts to app events, updating its attributes.
  6. View State reacts to App State changes, updating its attributes.
  7. View reacts to View State changes, updating the DOM.
// Listens to application level events and updates state attributes.
var AppState = Mn.State.extend({
  defaultState: {
    authenticated: false
  },

  componentEvents: {
    'login': 'onLogin',
    'logout': 'onLogout'
  },

  onLogin() {
    this.set('authenticated', true);
  },

  onLogout() {
    this.set('authenticated', false);
  }
});

// App controller fields application level requests and triggers application events.
var AppController = Mn.Object.extend({
  radioRequests() { return {
    'login': this.login,
    'logout': this.logout
  }},

  initialize(options={}) {
    this.channel = Radio.channel('app');
    this.state = new AppState({ component: this.channel });
    Radio.reply('app', this.radioRequests(), this);
  },

  login() {
    // Assume Backbone.$.ajax is shimmed to return ES6 Promises.
    return Backbone.$.ajax('/api/session', { method: 'POST' })
      .then(() => {
        this.channel.trigger('login');
      })
      .catch(() => {
        this.channel.trigger('logout');
      });
  },

  logout() {
    return Backbone.$.ajax('/api/session', { method: 'DELETE' })
      .then(() => {
        this.channel.trigger('logout');
      });
  },

  getState() {
    return this.state;
  }
});

// Syncs with application State.
var ToggleAuthState = Mn.State.extend({
  defaultState: {
    action: 'login'
  },

  appStateEvents: {
    'change:authenticated': 'onChangeAuthenticated'
  },

  // Sync with application state.
  initialize(options={}) {
    this.appState = options.appState;
    this.syncEntityEvents(this.appState, this.appStateEvents);
  },

  // Called on initialize and on change app 'authenticated'.
  onChangeAuthenticated(appState, authenticated) {
    if (authenticated) {
      this.set('action', 'logout');
    } else {
      this.set('action', 'login');
    }
  }
});

// Alternately a login or logout button depending on app authentication state.
var ToggleAuthView = Mn.ItemView.extend({
  template: 'This Button Label Will Be Replaced',
  tagName: 'button',

  triggers: {
    'click': 'loginLogout'
  },

  stateEvents: {
    'change:action': 'onChangeAction'
  },

  // Create and sync with my own State injected with app State.
  initialize(options={}) {
    this.appChannel = Radio.channel('app');
    this.state = new ToggleAuthState({
      appState: options.appState,
      component: this
    });
    Mn.State.syncEntityEvents(this, this.state, this.stateEvents, 'render');
  },

  // Button text will be updated on every render and 'action' change.
  onChangeAction(state, action) {
    this.$el.text(action);
  },

  // Login/logout toggle will always fire the appropriate action.
  loginLogout() {
    this.appChannel.request(this.state.get('action'));
  }
});

var appController = new AppController();
var appState = appController.getState();
var toggleAuthView = new ToggleAuthView({ appState: appState });

var appRegion = new Mn.Region({ el: '#app-region' });
appRegion.show(toggleAuthView);

Sub-Applications

Within an application modularized into sub-applications, state can cascade from app -> sub-app -> view. In this particular configuration, Radio can be used to make both sub-application and application requests.

Sub-Views

Within a deeply nested, complex view that requires a deeper layer of state, perhaps for child views within a CollectionView, state can cascade from app -> view -> sub-view.

State API

Initialization Properties

defaultState

Optional default state attributes hash. These will be applied to the underlying model when it is initialized.

componentEvents

Optional hash of component event bindings. Enabled by passing {component: <Evented object>} as an initialization option.

modelClass

Optional Backbone.Model class to instantiate, otherwise a pure Backbone.Model will be used.

Initialization Options

initialState

Optional initial state attributes. These attributes are combined with defaultState for initializing the underlying state model, and become the basis for future reset() calls.

component

Optional evented object to which to bind lifecycle and events. The componentEvents events hash is bound to component. When component fires 'destroy' the State instance is also destroyed, unless {preventDestroy: true} is also passed.

preventDestroy

Only applies when component is provided. By default, the State instance will destruct when component fires 'destroy', but {preventDestroy: true} will prevent this behavior.

Properties

attributes

Proxy to model attributes property. This permits a State instance to be used in place of a Backbone.Model within a Marionette view.

Methods

getModel()

Returns the underlying model.

getInitialState()

A clone of model's attributes at initialization.

get(attr)

Proxy to model get(attr).

set(key, val, options)

Proxy to model set(key, val, options).

reset(attrs, options)

Resets model to its attributes at initialization. If any attrs are provided, they will override the initial value. options are passed to the underlying model #set.

changedAttributes()

Proxy to model changedAttributes().

previousAttributes()

Proxy to model previousAttributes().

hasAnyChanged(...attrs)

Determine if any of the passed attributes were changed during the last modification.

var StatefulView = Mn.ItemView.extend({
  template: false,

  stateEvents: {
    'change': 'onStateChange'
  },

  initialize(options={}) {
    this.state = options.state;
    this.bindEntityEvents(this, this.state, this.stateEvents);
  },

  onStateChange(state) {
    if (!state.hasAnyChanged('foo', 'bar')) { return; }

    if (state.get('foo') && state.get('bar')) {
      this.$el.addClass('is-foo-bar');
    } else {
      this.$el.removeClass('is-foo-bar');
    }
  }
});
toJSON()

Proxy to model.toJSON().

bindComponent(component, options)

Bind componentEvents to component and self-destruct when component fires 'destroy'. This prevents a state from outliving its component and causing a memory leak. To prevent self-destruct behavior, pass {preventDestroy: true} as an option.

unbindComponent(component)

Unbind componentEvents from component and stop listening to component 'destroy' event.

syncEntityEvents(entity, bindings, event)

See syncEntityEvents)

var State = Mn.State.extend({
  entityEvents: {
    'change:foo': 'onChangeFoo'
  }

  initialize() {
    this.entity = new Backbone.Model({
      foo: true
    });
    this.syncEntityEvents(this, this.entity, this.entityEvents);
  },

  onChangeFoo(entity, foo) {
    if (foo) {
      this.$el.addClass('foo');
    } else {
      this.$el.removeClass('foo');
    }
  }
);

See State Functions API #syncEntityEvents.

Events

A State instance proxies events from its underlying model, substituting the model argument for the State instance.

'change' (state, options)

Fired when any attributes are updated, once per #set call.

'change:{attribute}' (state, value, options)

Fired when a specific attribute is updated.

State Functions API

sync(target, entity, bindings)

Calls Backbone entity event handlers in bindings located on target with standard Backbone event arguments. This is useful to apply event handlers without waiting for a change, such as for synchronization purposes. The following event handlers will be synced, and no others:

Backbone.Model
  'all'                (model)
  'change'             (model)
  'change:{attribute}' (model, value)

Backbone.Collection
  'all'                (collection)
  'reset'              (collection)
  'change'             (collection)

Notably, Collection 'add' and 'remove' event handlers will not be synchronized, because 'add' and 'remove' do not have a backing value (the added or removed element is not known until the event occurs). However, 'add remove reset' is syncable and also tracks with changes in the collection.

hasAnyChanged(entity, ...attrs)

Determine if any of the passed attributes were changed during the last modification.

var MyView = Mn.ItemView.extend({
  template: false,

  modelEvents: {
    'change': 'onChange'
  },

  onChange(model) {
    if (!Mn.State.hasAnyChanged(model, 'foo', 'bar')) { return; }

    if (state.get('foo') && state.get('bar')) {
      this.$el.addClass('is-foo-bar');
    } else {
      this.$el.removeClass('is-foo-bar');
    }
  }
});
syncEntityEvents(target, entity, bindings, event)

Registers event bindings bindings with entity using Mn.bindEntityEvents using target as context, and then synchronizes using sync(). If event is supplied, rather than syncing immediately, syncing will occur on every firing of event by target. This is useful for syncing a model to DOM within a View, for example. The standard event options object will contain the value syncing: true to indicate the call was made during a sync rather than an entity event.

Example without syncEntityEvents
var View = Mn.ItemView.extend({
  entityEvents: {
    'change:foo': 'onChangeFoo'
  }

  initialize() {
    this.entity = new Backbone.Model({
      foo: true 
    });
    this.bindEntityEvents(this.entity, this.entityEvents);
  },

  onChangeFoo(entity, foo) {
    if (foo) {
      this.$el.addClass('foo');
    } else {
      this.$el.removeClass('foo');
    }
  },

  onRender() {
    this.onChangeFoo(this.entity, this.entity.get('foo'), { syncing: true });
  }
});
Example with syncEntityEvents
var View = Mn.ItemView.extend({
  entityEvents: {
    'change:foo': 'onChangeFoo'
  }

  initialize() {
    this.entity = new Backbone.Model({
      foo: true
    });
    Mn.State.syncEntityEvents(this, this.entity, this.entityEvents, 'render');
  },

  onChangeFoo(entity, foo) {
    if (foo) {
      this.$el.addClass('foo');
    } else {
      this.$el.removeClass('foo');
    }
  }
);
Handling Multiple change:{attribute} Events

Just like Backbone, all handlers will be called for all supported events on sync. In the following binding, onChangeFooBar will be called twice on sync--once with the value of foo and once with the value of bar, similarly to if both foo and bar had changed at once.

modelEvents: { 'change:foo change:bar': 'onChangeFooBar' }

Because handlers called multiple times for a single sync is probably not desired behavior, the best practise to synchronize multiple attributes with a single handler is the same as standard Backbone: Listen for change and check model.changed for the presence particular attributes. The only addition is to check for whether handler was called during a sync.

modelEvents: { 'change': 'onChange' },

initialize() {
  var model = new Backbone.Model();
  Mn.State.syncEntityEvents(this, model, this.modelEvents);
},

onChange(model, options={}) {
  var syncOrChange = options.syncing || Mn.State.hasAnyChanged(model, 'foo', 'bar');
  if (!syncOrChange) { return; }

  // Either syncing or foo/bar have changed
}

When synchronizing with a State instance, this can become:

stateEvents: { 'change': 'onChange' },

initialize() {
  var state = new Mn.State();
  Mn.State.syncEntityEvents(this, state, this.stateEvents);
},

onChange(state, options={}) {
  var syncOrChange = options.syncing || state.hasAnyChanged('foo', 'bar');
  if (!syncOrChange) { return; }

  // Either syncing or foo/bar have changed
}