Mithril.js: A Tutorial Introduction (Part 2)
Welcome to part 2 of this tutorial series! In part 1 we implemented the EntryList
and EntryForm
components. In part 2 we will first refactor the EntryForm
component to take advantage of Mithril's stellar support for nested components. Then we will implement the Total
component and the Coupon
component, all of which will be managed by Mithril Virtual DOM algorithm under one parent component.
You can see the demo here!
Project User Stories
Before we dive back in, let's review our user stories:
As a volunteer, I want to sign up for an event.(Covered by part 1)As a volunteer, I want to sign up multiple people at once.(Covered by part 1)- As an organizer, I need at least one attendee email address to send event details to.
- As an organizer, I want to charge $10 per attendee.
- As an organizer, I want to provide coupons to offer a percent discount on total price.
As an organizer, I want to see all entered volunteer entries.(Covered by part 1)
New Components
Once we are done, the EntryForm
component will be the parent of three child components:
- The
Volunteers
component will manage the volunteers' information entered - The
Total
component will calculate the final price - The
Coupon
component will handle checking the validity of an entered coupon via AJAX
Getting Started
If you want to code along, clone down this repo and git checkout the mithril-part-1-solution
branch. This is the point we will start from.
Keep in mind that the purpose of this project is to teach Mithril concepts, so it uses some non-best practices. In a real project you would minimize the use of DOM ids, concatenate and minify your JavaScript files, wrap your code in anonymous function scopes, and so on.
Strategy
First we will factor out the volunteer contact management out of the EntryForm
and into a new component. Afterwards we will use Mithril's stellar support for nesting components to build and nest this and two more components. In the end, you will get to see how everything fits in one application.
This is the approach we will take:
- Extract some logic out of EntryForm into a new Volunteers component
- Refactor the EntryForm component
- Create the Total component
- Create the Coupon component
Enough talk, let's get started!
1. The Moderate Refactor
When a Mithril component is nested, its parent can specify component attributes. These attributes are received in a parameter called vnode
.
Let's create a new Volunteers component that "imports" its data from these attributes. This code is pulled straight from EntryForm with a tweak:
window.Volunteers = {
view(vnode) {
var {volunteers, add, remove} = vnode.attrs
var showRemove = (volunteers.length >= 2)
return m('.volunteers', [
volunteers.map(function (volunteer, idx) {
// [This is the same as before]
}),
// This button is the same too
m('button', { onclick: add }, 'Add another volunteer'),
])
}
}
New concept: See the vnode
? This little guy represents an instance of the component. Specifically, vnode.attrs
contains data from the component's parent (we'll see how this works in the next section). For now, recognize how we're receiving our data – volunteers
– and also our actions – add
and remove
.
With this, the Volunteers component is complete. It's a new component that is focused on displaying a list of volunteers. Put differently, this component has the sole responsibility of rendering the volunteer management user interface (UI).
One more point: we did not bring over the h3
element. Because we intend this component to be reusable, we leave that responsibility to the parent (you'll see this in the next section).
2. Refactoring the EntryForm Component
Now that we have a Volunteers
component, let's refactor EntryForm
to make use of it, starting with the view:
// src/components/EntryForm.js
EntryForm.view = function () {
// ...[truncated]...
return {
view() {
return m('.entry-form', [
m('h1', "New Entry"),
m('h3', "Please enter each volunteer's contact information:"),
// New!
m(Volunteers, {
add: add,
remove: remove,
volunteers: entry.volunteers
}),
m('br'),
m('button', { onclick: submit }, 'Submit'),
])
}
}
}
// Don't forget to delete removeAnchor!
This is where it gets exciting! We've refactored EntryForm
to nest the Volunteers
component using m()
. Given this code, when it comes time to render the page, Mithril will automatically initialize the nested component and run its view function, all without the need for us to manage it at all.
Notice also how we're passing add
, remove
, and entry.volunteers
to the child Volunteers
component. This object is the vnode.attrs
object we saw in the previous section.
Note how our component actions did not have to change. Our refactor is complete!
If you're coding along, refresh the page and try creating a new entry. Everything should still work the same.
3. The Total Component
It's time to write the Total component. This component will display the total amount the user needs to donate for the signup application. Let's begin with the view:
// src/components/Total.js
window.Total = {
view(vnode) {
var {count, discount} = vnode.attrs
return m('.total', [
m('label', "Total: "),
m('b', "$" + calculatePrice(discount, count))
])
}
}
Here calculatePrice
(implementation not shown yet) is a model function that calculates the final price based on the component attributes. This is important to not only making the Total component reusable, but making the price calculation reusable as well (we will see the definition of this function shortly).
Here is the model logic. You can put this in the same file:
// src/components/Total.js
/* Model logic */
var PRICE_PER_COUNT = 10
function calculatePrice (discount, count) {
var total = count * PRICE_PER_COUNT
return roundCents(total - total * discount)
}
function roundCents (num) {
return Math.round(num * 100) / 100
}
As an aside, the nice thing about writing Total as its own component is we now have one place to go to if we need to modify anything total-related – whether it's changing how it looks on the page (Total.view
) or changing the business logic (calculatePrice
). Of course, if this file start to get too big, you can always split it up into multiple files in proper folders.
Nesting Total in VolunteerForm
Now we need to put the Total component on the page. This will require two updates:
- Add a
discount
property to the EntryForm component - Nest the Total component within the EntryForm view and send down
discount
and number of volunteers as attributes.
Here is EntryForm with the above updates:
// src/components/EntryForm.js
window.EntryForm = function () {
var entry = Entry.vm()
var discount = 0 // <-- New! (1.)
// Component actions ...[trucated]...
return {
view() {
return m('.entry-form', [
m('h1', "New Entry"),
m('h3', "Please enter each volunteer's contact information:"),
m(Volunteers, {
add: add,
remove: remove,
volunteers: entry.volunteers
}),
m(Total, { // <-- New! (2.)
count: entry.volunteers.length,
discount: discount
}),
m('br'),
m('button', { onclick: submit }, 'Submit'),
])
}
}
}
- Here we create new view state variable
discount
. It's generally a good idea to attach all your model data to the top-level component, and then pass down data to child components as necessary. - Here is another example of nesting a component. This time we pass two attributes. Note how the
Total
component's view does not need to know wherevnode.attrs.count
originates from.
If you're coding along, refresh the page and visit the new entry form. You should see the total update as you add and remove attendees.
4. The Coupon Component
Lastly, let's implement the Coupon component. As mentioned in part 1, this tutorial will not cover server-side code. Instead, we will hard code some logic and take comment on what would normally happen on the server side.
This component will be the most complicated one we've implemented so far. It needs to do the following:
- Allow the user to type in the coupon code
- Validate the user's coupon code on coupon form submit
- If valid, then discount the total amount
- If invalid, then display an error
To accomplish this, we will take the following approach:
I. Implement the Coupon state & actions
II. Implement the Coupon view
III. Include Coupon in the VolunteerForm component
IV. Add error handling for invalid coupons
V. Show coupon discount in Total component view
I. The Coupon State
Let's first take a look at the state for the Coupon component:
// src/components/Coupon.js
window.Coupon = function () {
var code = '' // 1. //
function submit (onSuccess) { // 2. //
validateCoupon(code) // 3. //
.then(function (discount) { // 4. //
alert('Coupon applied!')
code = ''
onSuccess(discount)
})
}
// return {...[truncated]...}
}
code
is our view-state; it's where we will store the coupon string typed in by the user.submit
is the function that will run when the user submits the coupon code form (you'll see the form in the next subsection).validateCoupon
is the function that validates the user's coupon code against the server via AJAX. However, in our case this function will mock the AJAX request instead of hitting a real server. We will see the implementation of this function soon.- Another thing we expect
validateCoupon
to do is return a promise. When this promise resolves, we inform the user the coupon is valid, reset the input field, and callonSuccess
with the new discount.
Note that onSuccess
will come from the parent component (we will see how shortly). Because of this, we can say Coupon is a callback-style component.
Now let's look at the implementation of validateCoupon
:
// src/components/Coupon.js
function validateCoupon (code) {
var isValid = (code === 'happy')
var discount = 0.20
// Mock AJAX promise
if (isValid) return Promise.resolve(discount)
else return Promise.reject('invalid_code')
}
-
As you can see, we hardcode the validation in the first line of this function. In a real application, this logic (and the discount amount) would be hidden on the server.
-
Here we mock an AJAX request by using the built-in JS promise helpers. If the coupon is valid, we resolve with the discount amount. If not, we reject with an error message. The key point to mocking is to resolve and reject with values that would normally come from a server.
-
Finally, we return the promise so that other code (such as the submit action) can call
.then()
, just as if it were a real async AJAX request.
In general, mocking allows us to simulate an AJAX request without actually touching a server. This allows us to continue developing as if we did have a server, postponing the work of writing one until later (excellent for prototyping).
II. The Coupon View
The view is more of the same, but with a slight twist:
// src/components/Coupon.js
window.Coupon = function () {
// ...[truncated] The stuff we just wrote
return {
view(vnode) {
var {onSuccess} = vnode.attrs
return m('form', {
onsubmit(e) {
e.preventDefault()
submit(onSuccess) // ~-~Twist!~-~
}
}, [
m('label', "Enter coupon (if you have one):"),
m('input[type=text]', {
value: code,
onchange(e) {
code = e.target.value
}
}),
m('button[type=submit]', "Validate coupon")
])
}
}
}
Here we handle the submit form event. First we run e.preventDefault()
to prevent the page from refreshing. Then we run submit(onSuccess)
to start the coupon submit process. The twist is how we pass onSuccess
from vnode.attrs
to the submit action!
By the time the user submits, code
will contain the user's text input due to the two-way data binding between it and the input field.
III. Nesting Coupon into EntryForm
Revisiting the EntryForm
view, we can now nest the Coupon
component:
// src/components/EntryForm.js
// ...
view() {
return m('.entry-form', [
m('h1', "New Entry"),
m('h3', "Please enter each volunteer's contact information:"),
m(Volunteers, {
add: add,
remove: remove,
volunteers: entry.volunteers
}),
m(Total, {
count: entry.volunteers.length,
discount: discount
}),
// New!
m(Coupon, {
onSuccess(newDiscount) {
discount = newDiscount
}
}),
m('br'),
m('button', { onclick: submit }, 'Submit'),
])
}
Clean! The onSuccess
callback we pass to Coupon
updates the value of discount
. Afterwards, Mithril will redraw, passing the new value of discount
to the the Total
component to the correct state!
If you're coding along, refresh the page and take note of the total amount. Enter happy
into the coupon code form and click the button. The total changes! Try adding and removing attendees. The discount still applies! :)
IV. Error handling for invalid coupons
The happy path works perfectly. However, we still need to give visual feedback when the user types in an invalid code. Fortunately, Mithril will make this easy.
Let's go back to the Coupon component and add a bit of view state:
window.Coupon = function () {
var code = ''
var error = null // 1. //
function submit = function () {
error = null // 2. //
validateCoupon(code)
.then(/* ...[truncated]... */)
.catch(function(err) { // 3. //
error = err
})
}
}
-
An error message is view state. Here we initialize it with
null
, indicating there is currently no error. The view will use thiserror
variable to determine whether or not to display error information. -
On submit, we always clear the error. This will hide any previous error message that might be on the page.
-
Here we added
error
as a parameter to.then()
. As it turns out,.then()
can take two parameters: a success callback, and a failure callback. In this case, any reject error value fromvalidateCoupon
will feed into our failure callback, updating the value oferror
.
Ok, now let's use our new error
variable within our view:
// ...
view(vnode) {
var {onSuccess} = vnode.attrs
return m('form', {
onsubmit(e) {
e.preventDefault()
submit(onSuccess)
}
}, [
error && m('.error', error),
// ...[truncated]...
])
}
If you're coding along, refresh the page, type in an invalid coupon code, and submit. See the error message? Now try submitting happy
. Cool! Not only does the discount apply, but the error message also disappears :)
V. Showing the discounted amount
The last thing we will do (in part 2) is show the user how much money they're "saving" with their coupon. Intuitively, we will do this in the Total component's view. In fact, we won't have to touch the Coupon component at all :)
Let's revisit the Total
component. First, a minor refactor to calculatePrice
:
function calculatePrice (discount, count) {
var total = calculateTotal(count)
return roundCents(total - total * discount)
}
function calculateTotal (count) {
return count * PRICE_PER_COUNT
}
Here we pull out the logic for calculating the total into its own function. It's not bad to repeat yourself, but business logic should almost always be DRY.
Next, let's write a helper function for our discount view:
function discountView (count, discount) {
var total = calculateTotal(count)
var discountedAmount = total * discount
return m('span', "(Coupon discount: -$" + discountedAmount + ")")
}
Lastly, use this helper in the main view:
window.Total = {
view(vnode) {
var {count, discount} = vnode.attrs
return m('.total', [
m('label', "Total: "),
discount > 0 &&
discountView(count, discount),
m('b', "$" + calculatePrice(discount, count))
])
}
}
// ...
Notice how I indent discountView
. This small trick makes &&
conditionals a bit more readable.
If you're coding along, refresh the page, enter happy
as a coupon code, and try adding/removing volunteers. You should now see the discounted amount appear and update automatically :)
Conclusion
We have written four components in a top-down component architecture. EntryForm is the top-level parent component that has three child components:
- Volunteers, a stateless, callback-style component. It receives callbacks to manipulate data from its parent.
- Total, a plain stateless component. It displays the total price information and has no state of its own.
- Coupon, another stateless, callback-style component. It handles validation and runs a parent-provided callback when validation passes.
Because EntryForm is the top-level component, it holds all the state and model data, passing them down to its children as necessary.
At the end of the day, Mithril's component features gives us a lot of flexibility for organizing and gluing together our code. Used properly, we can achieve succinct code that is easy to reason about, yet still accomplishes a great deal of functionality.
In part 3 (not yet released), we will handle submission and validation of the entire form, as well as handle some new user stories that involve changing the UX. Until next time!