Skip to content

Instantly share code, notes, and snippets.

@ianmstew
Last active July 14, 2017 23:49
Show Gist options
  • Save ianmstew/d6b5377840d3aaf4e93a to your computer and use it in GitHub Desktop.
Save ianmstew/d6b5377840d3aaf4e93a to your computer and use it in GitHub Desktop.

state-domain-mn-architecture.js is one approach to separating application state mangement from view state. Application state is maintainted centrally by "Domain Controllers" linked to an event bus that each manage a model or collection. Application state models are then passed into the view hierarchy. Views can use Marionette.State to express their own data needs and "sync" what they need from application state. A Marionette.State instance, in this approach, lives and dies with its View and also handles view events to establish uni-directional, closed-loop management of local view state.

For a more digestable view of Marionette.State's utility in terms of views, see the following JSBin, which addresses the utility of Marionette.State without an opinion about greater application state architecture. It is a working, slimmed-down example of a view spawning its own State instance that serves to consolidate what it needs from global application state models.

https://jsbin.com/huvovoyexe/edit?html,js,output

// Domain with a generic model
const AppDomain = Marionette.Object.extend({
channelName: 'app',
// Return an object from a function so
radioRequests() { return {
'activate:tab': this.activateTab,
};},
defaultState: {
activeTab: 'foo',
},
initialize() {
this.app = new Backbone.Model(this.defaultState);
Radio.reply(this.channelName, this.radioRequests(), this);
},
getModel() {
return this.app;
},
activateTab(activeTab) {
this.app.set({
activeTab,
});
},
});
const User = Backbone.Model.extend({
defaults: {
id: null,
name: '',
},
});
// Domain with a custom model, User
const UserDomain = Marionette.Object.extend({
channelName: 'user',
radioRequests() { return {
'fetch': this.fetch,
};},
initialize() {
this.user = new User();
Radio.reply(this.channelName, this.radioRequests(), this);
},
getModel() {
return this.user;
},
fetch() {
return this.user.fetch();
},
});
const Search = Backbone.Model.extend({
defaults: {
query: '',
isSearching: false,
},
initialize() {
this.results = new SearchResults();
},
search(query) {
this.set({
query,
isSearching: true
});
return this.results.search(query).then(() => {
this.set({
isSearching: false,
});
});
},
});
// Domain with a custom model, Search, that has a nested collection, Results
const SearchDomain = Marionette.Object.extend({
channelName: 'search',
radioRequests() { return {
'search': this.search,
};},
initialize() {
this.search = new Search();
Radio.reply(this.channelName, this.radioRequests(), this);
},
getModel() {
return this.search;
},
search(query) {
return this.search.search(query);
},
});
const SearchBarView = Marionette.LayoutView.extend({
modelEvents: {
'change:search': (model, query) => { this.updateQuery(query); },
},
ui: {
bar: '.js-bar',
},
events: {
'submit': 'onSubmit'
},
onSubmit(evt) {
evt.preventDefault();
Radio.request('search', 'search', this.ui.bar.val());
},
// Manual DOM updating so that search input doesn't lose focus.
// Called onChange and onRender to ensure it is always synced to `query`.
updateQuery(query) {
this.ui.bar.val(query);
},
onRender() {
this.updateQuery(this.model.get('query'));
}
});
const SearchView = Marionette.LayoutView.extend({
regions: {
searchBar: '.js-search-bar',
results: '.js-results',
}
constructor({ state } = {}) {
this.state = state;
},
onRender() {
this.getRegion('searchBar')).show(new SearchBarView({
model: this.state.search,
}))
this.getRegion('results').show(new ResultsView({
collection: this.state.search.results,
}));
}
});
// HeaderState serves to both distill ("select" in Redux) state from state slices for view purposes
// and to field view-specific events to manage view-specific state.
// This can be accomplished without Marionette.State in exchange for some auxiliary code.
const HeaderState = Marionette.State.extend({
defaultState: {
pageTitle: '',
userId: '',
userName: '',
userHighlighted: false,
},
// View events that handle truly view-specific state (essentially glorified DOM state). Yes, this could
// also be handled through a `header` state slice. Which to use would be an interesting discussion.
componentEvents: {
'highlightIn:user': 'onHighlightInUser',
'highlightOut:user': 'onHighlightOutUser',
},
appStateEvents: {
'change': 'onAppChange',
},
userStateEvents: {
'change': 'onUserChange',
},
initialize(state) {
this.state = state;
this.syncEntityEvents(this.state.app, this.appStateEvents);
this.syncEntityEvents(this.state.user, this.userStateEvents);
},
_toPageTitle(activeTab) {
return camelToSpaced(activeTab);
},
onAppChange(app) {
this.set({
pageTitle: this._toPageTitle(app.get('activeTab')),
});
},
onUserChange(user) {
this.set({
userId: user.id,
userName: user.get('name'),
});
},
onHighlightInUser() {
this.set({
userHighlighted: true,
});
},
onHighlightOutUser() {
this.set({
userHighlighted: false,
});
},
});
// Example of a view that creates its own view model, which can distill what it needs from
const HeaderView = Marionette.LayoutView.extend({
// Obviously, precompiled templates would be preferable here.
template: _.template(`
<h1><%- pageTitle %></h1>
<a class='js-user header__user <%- userHighlightedClass %>' href="/users/<%- userId %>">
<%- userName %>
</a>
`);
className: 'header',
ui: {
user: '.js-user',
},
// Fielded by HeaderState
triggers: {
'mouseenter @ui.user, focus @ui.user': 'highlightIn:user',
'mouseleave @ui.user, blur @ui.user': 'highlightOut:user',
},
// Since BB 1.2.1, assigning to `model` must happen in the constructor rather than `initialize`.
constructor({ state } = {}) {
this.model = new HeaderState({
state,
component: this,
});
HeaderView.__super__.constructor.apply(this, arguments);
},
templateHelpers: {
userHighlightedClass() {
return this.userHighlighted ? 'header__user--highlighted' : ''
}
},
});
const AppLayout = Marionette.LayoutView.extend({
regions: {
header: '.js-header',
search: '.js-search',
content: '.js-content',
}
appStateEvents: {
'change:activeTab': (app, activeTab) => { this.showActiveTab(activeTab); }
},
initialize({ state } = {}) {
this.state = state;
this.bindEntityEvents(this.state.app, this.appStateEvents);
},
onRender() {
this.getRegion('search').show(new SearchView({
state: this.state,
}));
this.getRegion('header').show(new HeaderView({
state: this.state,
}))
this.showActiveTab(this.state.app.get('activeTab'));
},
// Called onChange and onRender to ensure content region is bound to `activeTab`.
showActiveTab(activeTab) {
if (activeTab === 'foo') {
this.getRegion('content').show(new FooView());
} else {
this.getRegion('content').show(new BarView());
}
},
});
const Application = Marionette.Application.extend({
initialize() {
// One idea to inject state into view tree _sort of_ like Redux, where each "slice" is its own Backbone.Model.
// Each "domain" is a headless business service which processes events and ultimately modifies a model.
this.state = {
app: new AppDomain().getModel(),
user: new UserDomain().getModel(),
search: new SearchDomain().getModel(),
};
},
start() {
Radio.request('user', 'fetch').then(this.renderLayout.bind(this));
},
renderLayout() {
this.layout = new AppLayout({
el: 'body',
state: this.state,
}).render();
}
});
new Application().start();
@ricky-wong
Copy link

For anyone who might stumble on this(?)

Marionette URL in the jsbin now points to Marionette 3, which won't work with the code in jsbin.

Switch back to Marionette 2 by changing
https://cdn.rawgit.com/marionettejs/backbone.marionette/master/lib/backbone.marionette.js
to
https://cdn.rawgit.com/marionettejs/backbone.marionette/v2.4.7/lib/backbone.marionette.js

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment