Last year we switched from pure BackboneJS to MarionetteJS. For those who don’t know, Marionette is a fantastic little MVC framework that builds upon the wireframe that Backbone gives you. It does a great job of “filling in the blanks” for you so that you can concentrate on getting your app up and running while not losing track of everything as your project grows in size.
Here I’ll be sharing a few tips I learnt along the way on how you can make those Marionette views better and easier to manage.
I’ll be sharing code in CoffeeScript as it is the language we have adopted in Quipper, and it’ll make for shorter, more concise examples.
So let’s start with a typical controller and view as we may have once written it.
class Controller extends Marionette.Controller
show: ->
@model = new Person
@view = new PersonView model: @model
app.mainRegion.show(@view) # assuming that this is our region to render views in
class PersonView extends Marionette.ItemView
template: Tpl['person']
ui:
save: 'a.btnSave'
cancel: 'a.btnCancel'
events:
'click @ui.save': 'clickSave'
'click @ui.cancel': 'clickCancel'
clickSave: (e) ->
e.preventDefault()
e.stopPropagation()
@ui.save.addClass('disabled')
@model.save().done(@saved).fail(@failed)
saved: =>
# respond to save
failed: =>
# respond to failure
clickCancel: (e) ->
e.preventDefault()
e.stopPropagation()
# navigate to somewhere else
Hook yourself up with some Triggers.
Honestly, triggers are a godsend.
They automatically prevent the default action for DOM events and turn the event into a call to
triggerMethod
so that we can listen to the event elsewhere.
In the world of MVC this is great, because it means that the controller can do its job properly: to control a view and respond to actions, just as a Rails app might do.
Here’s how it looks with triggers to replace the events.
class Controller extends Marionette.Controller
show: ->
@model = new Person
@view = new PersonView model: @model
@listenTo @view, 'click:save', @clickSave
@listenTo @view, 'click:cancel', @clickCancel
app.mainRegion.show(@view) # assuming that this is our region to render views in
clickSave: (args) ->
args.view.ui.save.addClass('disabled')
args.model.save().done(@saved).fail(@failed)
saved: =>
# respond to save
failed: =>
# respond to failure
clickCancel: (e) ->
# navigate to somewhere else
class PersonView extends Marionette.ItemView
template: Tpl['person']
ui:
save: 'a.btnSave'
cancel: 'a.btnCancel'
triggers:
'click @ui.save': 'click:save'
'click @ui.cancel': 'click:cancel'
So it looks like we’ve just moved some of the functions into the controller, and got rid of preventDefault
and stopPropagation
(because Marionette does that automatically for triggers). But now we’ve also got
some DOM manipulation in the controller.
args.view.$('.btn').addClass('disabled')
That doesn’t look right. The controller shouldn’t be manipulating the DOM and making UI changes, because:
- It’s lazy.
- It’s not very MVC.
- As our app grows, it’ll become harder and harder to find which parts of the code are updating the UI.
Marionette’s triggerMethod is MAGIC. Sort of.
Whenever Marionette fires an event via triggerMethod
which is what the triggers
hash uses,
it automatically checks if an on
method exists for the trigger name, where each word is separated
by colons. do:something:cool
becomes onDoSomethingCool
. Let’s make use of that in the view.
class PersonView extends Marionette.ItemView
template: Tpl['person']
ui:
save: 'a.btnSave'
cancel: 'a.btnCancel'
triggers:
'click @ui.save': 'click:save'
'click @ui.cancel': 'click:cancel'
onClickSave: ->
@ui.save.addClass('disabled')
Thanks, triggers! So what’s next?
Pass the ball.
Sometimes we still need to pass control back to the view from the controller. For example, we have
a handler for when the model’s save
method fails. But what if it needs to make some UI changes,
or focus on a textbox? We shouldn’t call the UI from the controller.
One of the main jobs of a model is to be a mediator between View and Controller. Backbone sends
events when models are saved or have problems, so we can use this to our advantage. Let’s use
modelEvents
and stop listening to our $.Deferred
response.
class Controller extends Marionette.Controller
show: ->
@model = new Person
@view = new PersonView model: @model
@listenTo @view, 'click:save', @saveModel
@listenTo @view, 'click:cancel', @cancel
@listenTo @model, 'sync', @synced
@listenTo @model, 'error', @failed
app.mainRegion.show(@view) # assuming that this is our region to render views in
saveModel: (args) ->
args.model.save()
synced: ->
# respond to model being saved
cancel: (e) ->
# respond to cancel action
failed: (e) ->
# respond to model failing
class PersonView extends Marionette.ItemView
template: Tpl['person']
ui:
save: 'a.btnSave'
cancel: 'a.btnCancel'
triggers:
'click @ui.save': 'click:save'
'click @ui.cancel': 'click:cancel'
modelEvents:
sync: 'synced'
error: 'failed'
onClickSave: ->
@ui.save.addClass('disabled')
synced: ->
# update UI as necessary
failed: ->
# update UI as necessary
It looks longer, but that’s because we have now completely separated view from controller logic. Most of the implementation here is optional.
And check out some of the benefits!
- We have a clear workflow: The view takes care of UI and the controller takes care of action.
- We’re less likely to get lost in the future when trying to debug the UI or logic.
- We have less unit tests to write because most of the triggering is done internally by Marionette.
- Our controller and view are very decoupled and can be tested independently of each other.