Components
Avoid anti-patterns
Although Mithril.js is flexible, some code patterns are discouraged:
Avoid fat components
Generally speaking, a "fat" component is a component that has custom instance methods. In other words, you should avoid attaching functions to vnode.state or this. It's exceedingly rare to have logic that logically fits in a component instance method and that can't be reused by other components. It's relatively common that said logic might be needed by a different component down the road.
It's easier to refactor code if that logic is placed in the data layer than if it's tied to a component state.
Consider this fat component:
// views/Login.js
// AVOID
var Login = {
username: '',
password: '',
setUsername: function (value) {
this.username = value
},
setPassword: function (value) {
this.password = value
},
canSubmit: function () {
return this.username !== '' && this.password !== ''
},
login: function () {
/*...*/
},
view: function () {
return m('.login', [
m('input[type=text]', {
oninput: function (e) {
this.setUsername(e.target.value)
},
value: this.username,
}),
m('input[type=password]', {
oninput: function (e) {
this.setPassword(e.target.value)
},
value: this.password,
}),
m('button', {disabled: !this.canSubmit(), onclick: this.login}, 'Login'),
])
},
}
Normally, in the context of a larger application, a login component like the one above exists alongside components for user registration and password recovery. Imagine that we want to be able to prepopulate the email field when navigating from the login screen to the registration or password recovery screens (or vice versa), so that the user doesn't need to re-type their email if they happened to fill the wrong page (or maybe you want to bump the user to the registration form if a username is not found).
Right away, we see that sharing the username and password fields from this component to another is difficult. This is because the fat component encapsulates its state, which by definition makes this state difficult to access from outside.
It makes more sense to refactor this component and pull the state code out of the component and into the application's data layer. This can be as simple as creating a new module:
// models/Auth.js
// PREFER
var Auth = {
username: '',
password: '',
setUsername: function (value) {
Auth.username = value
},
setPassword: function (value) {
Auth.password = value
},
canSubmit: function () {
return Auth.username !== '' && Auth.password !== ''
},
login: function () {
/*...*/
},
}
module.exports = Auth
Then, we can clean up the component:
// views/Login.js
// PREFER
var Auth = require('../models/Auth')
var Login = {
view: function () {
return m('.login', [
m('input[type=text]', {
oninput: function (e) {
Auth.setUsername(e.target.value)
},
value: Auth.username,
}),
m('input[type=password]', {
oninput: function (e) {
Auth.setPassword(e.target.value)
},
value: Auth.password,
}),
m(
'button',
{
disabled: !Auth.canSubmit(),
onclick: Auth.login,
},
'Login',
),
])
},
}
This way, the Auth module is now the source of truth for auth-related state, and a Register component can easily access this data, and even reuse methods like canSubmit, if needed. In addition, if validation code is required (for example, for the email field), you only need to modify setEmail, and that change will do email validation for any component that modifies an email field.
As a bonus, notice that we no longer need to use .bind to keep a reference to the state for the component's event handlers.
Don't forward vnode.attrs itself to other vnodes
Sometimes, you might want to keep an interface flexible and your implementation simpler by forwarding attributes to a particular child component or element, in this case Bootstrap's modal. It might be tempting to forward a vnode's attributes like this:
// AVOID
var Modal = {
// ...
view: function (vnode) {
return m('.modal[tabindex=-1][role=dialog]', vnode.attrs, [
// forwarding `vnode.attrs` here ^
// ...
])
},
}
If you do it like above, you could run into issues when using it:
var MyModal = {
view: function () {
return m(
Modal,
{
// This toggles it twice, so it doesn't show
onupdate: function (vnode) {
if (toggle) $(vnode.dom).modal('toggle')
},
},
[
// ...
],
)
},
}
You could also run into errors if the element you're forwarding it to is in a fragment:
// AVOID
var TaskItem = {
// ...
view: function (vnode) {
return [
m('.TaskList__TaskItem.Layout__Container', vnode.attrs, [
// forwarding `vnode.attrs` here ^
// ...
]),
m('.TaskList__TaskButtons.Layout__Container', [
// ...
]),
]
},
}
var MyList = {
view: function () {
return m(
'div.TaskList__Container',
Model.taskItems().map(function (item) {
return m(TaskItem, {
// This attribute gets forwarded to the
// first element returned by `TaskItem`.
// This makes the element keyed, and thus
// causes the fragment it's in to have a
// mix of keyed and unkeyed elements.
//
// Mithril checks for such mixed keys in
// its vnode factories, and so you'd see
// an error thrown.
key: item.id,
// ...
})
}),
)
},
}
Instead, you should use m.censor to remove all the problem lifecycle methods and attributes:
// PREFER
var Modal = {
// ...
view: function (vnode) {
return m('.modal[tabindex=-1][role=dialog]', m.censor(vnode.attrs), [
// forwarding `vnode.attrs` here ^
// ...
])
},
}
// PREFER
var TaskItem = {
// ...
view: function (vnode) {
return [
m('.TaskList__TaskItem.Layout__Container', m.censor(vnode.attrs), [
// forwarding `vnode.attrs` here ^
// ...
]),
m('.TaskList__TaskButtons.Layout__Container', [
// ...
]),
]
},
}
Also, consider using a single attribute instead of forwarding them directly. It may make for clearer, cleaner code in some cases.
// PREFER
var Modal = {
// ...
view: function (vnode) {
return m('.modal[tabindex=-1][role=dialog]', vnode.attrs.attrs, [
// forwarding `attrs:` here ^
// ...
])
},
}
// Example
var MyModal = {
view: function () {
return m(Modal, {
attrs: {
// This toggles it once
onupdate: function (vnode) {
if (toggle) $(vnode.dom).modal('toggle')
},
},
// ...
})
},
}
Don't manipulate children
If a component is opinionated in how it applies attributes or children, you should switch to using custom attributes.
Often it's desirable to define multiple sets of children, for example, if a component has a configurable title and body.
Avoid destructuring the children property for this purpose.
// AVOID
var Header = {
view: function (vnode) {
return m('.section', [m('.header', vnode.children[0]), m('.tagline', vnode.children[1])])
},
}
m(Header, [m('h1', 'My title'), m('h2', 'Lorem ipsum')])
// awkward consumption use case
m(Header, [[m('h1', 'My title'), m('small', 'A small note')], m('h2', 'Lorem ipsum')])
The component above breaks the assumption that children will be output in the same contiguous format as they are received. It's difficult to understand the component without reading its implementation. Instead, use attributes as named parameters and reserve children for uniform child content:
// PREFER
var BetterHeader = {
view: function (vnode) {
return m('.section', [m('.header', vnode.attrs.title), m('.tagline', vnode.attrs.tagline)])
},
}
m(BetterHeader, {
title: m('h1', 'My title'),
tagline: m('h2', 'Lorem ipsum'),
})
// clearer consumption use case
m(BetterHeader, {
title: [m('h1', 'My title'), m('small', 'A small note')],
tagline: m('h2', 'Lorem ipsum'),
})
Define components statically, call them dynamically
Avoid creating component definitions inside views
If you create a component from within a view method (either directly inline or by calling a function that does so), each redraw will have a different clone of the component. When diffing component vnodes, if the component referenced by the new vnode is not strictly equal to the one referenced by the old component, the two are assumed to be different components even if they ultimately run equivalent code. This means components created dynamically via a factory will always be re-created from scratch.
For that reason you should avoid recreating components. Instead, consume components idiomatically.
// AVOID
var ComponentFactory = function (greeting) {
// creates a new component on every call
return {
view: function () {
return m('div', greeting)
},
}
}
m.render(document.body, m(ComponentFactory('hello')))
// calling a second time recreates div from scratch rather than doing nothing
m.render(document.body, m(ComponentFactory('hello')))
// PREFER
var Component = {
view: function (vnode) {
return m('div', vnode.attrs.greeting)
},
}
m.render(document.body, m(Component, {greeting: 'hello'}))
// calling a second time does not modify DOM
m.render(document.body, m(Component, {greeting: 'hello'}))
Avoid creating component instances outside views
Conversely, for similar reasons, if a component instance is created outside of a view, future redraws will perform an equality check on the node and skip it. Therefore component instances should always be created inside views:
// AVOID
var Counter = {
count: 0,
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + vnode.state.count),
m(
'button',
{
onclick: function () {
vnode.state.count++
},
},
'Increase count',
),
)
},
}
var counter = m(Counter)
m.mount(document.body, {
view: function (vnode) {
return [m('h1', 'My app'), counter]
},
})
In the example above, clicking the counter component button will increase its state count, but its view will not be triggered because the vnode representing the component shares the same reference, and therefore the render process doesn't diff them. You should always call components in the view to ensure a new vnode is created:
// PREFER
var Counter = {
count: 0,
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + vnode.state.count),
m(
'button',
{
onclick: function () {
vnode.state.count++
},
},
'Increase count',
),
)
},
}
m.mount(document.body, {
view: function (vnode) {
return [m('h1', 'My app'), m(Counter)]
},
})