Tame Mithril's Router to Write Clear Apps
I use Mithril and I seriously love it! Sadly, the router alone has been enough of a pain point to make me rethink how to use Mithril. I will use profanity on the subject as a coping mechanism while I write, but don’t let that fool you into thinking that I blame anyone but myself for any bad code I write, or that I reserve anything other than respect for Mithril’s patient and knowledgeable maintainers.
If you don’t know what a router is or does, don’t worry. I wrote a section to get you up to speed.
For those of you new to Mithril, I want to flatten your learning curve for the v1.1.x Mithril router and then show you how taming the router tames your Mithril apps. Everything I will show you can already be found in the documentation, but I read the docs and still made “gotcha” mistakes that didn’t register until they happened. My hope is that by paraphrasing the docs in terms of the router’s quirks, others won’t have as frustrating of an experience. For that reason, if you are already familiar with single-page application (SPA) routers this article will still help you avoid painting yourself into some corners.
Once we’re squared away on the router I will show you how to cleanly separate routing policy to make your app easier to change. Finally, I’ll show you one demo Mithril application organized in this way.
I assume you are either curious about getting started with Mithril or have used it just enough to wonder how to improve your use of it. I also assume that you know how to write Mithril components.
Preamble for routing newbies
If you’ve used a SPA router before, skip this section.
A SPA router takes a path you type into the address bar and navigates
the end-user to a specific spot in your app. Unlike how navigation
traditionally works, SPA routers do not refresh the browser when the
user moves about. This makes web applications feel more cohesive and
fluid. A route might look like /pages/home
or
#!/shop/checkout
, depending on what part of the web address
the router uses to track the user’s location.
Typically routers want you to set them up by telling them all the places the user can go, along with what happens to the user at each of those places. This can be done in several ways. Sometimes you give a router a big object (sometimes called a routing table) with the routes as keys and some implementation-specific type as values. Routing tables typically show clear intent.
router.config({
'/': HomePage,
'/about': CorporateInfo,
'/shop': Storefront,
'/support': HelpDesk,
});
Other routers might give you a decorator function that mark other functions to handle specific routes. The premise is the same.
@router.route('/')
function HomePage() {
// ...
}
@router.route('/about')
function CorporateInfo() {
// ...
}
You use routers to map out your application from a navigator’s perspective. Routers tend to have other features, such as parsing values from routes as function arguments, or history manipulation. The Mithril router is just one of many variants out there.
Mithril’s Router
You summon Mithril’s router using the m.route()
function, a
routing table, a DOM node that will hold rendered content, and a
default route. Here’s an example
CodePen.
Each route can be associated with either a Mithril component or a
RouteResolver object (discussed later) that commands more
refined control. In the above example, I use a Mithril component that
will print Hello, Sage!
when you visit
the /hello/sage
route. You can see that the name was parsed
out of the route itself by Mithril.
In your code, you can tell the router to navigate somewhere using
m.route.set()
. You can ask for the last resolved route using
m.route.get()
. You can parse our variables in your route
using m.route.param()
.
For these simple cases, that’s all you need to know. But I want to jump right into the gotchas lest you end up with a false sense of security.
Mithril resolves routes on its own schedule
The Mithril router resolves routes asynchronously. Say if you
are on a /cart
page and you want to go to your profile page
at /profile
. You might use
m.route.set()
to navigate. But if your code then asks
Mithril’s router for the current location with m.route.get()
,
your state is going to be wrong.
This
example shows a view that correctly prints the active route, but the
console.log
shown in the example says the route
is undefined
right after m.route.set()
kicks off
navigation. That’s because m.route.get()
only tells you the
last resolved route, which can lead to confusing bugs if
you use m.route.get()
to update a view model at a bad time.
To expand on this point, did you ever make a view that looks like it
has data from one page “ago”? If so, then you used
m.route.get()
or m.route.param()
to change state out
of sync with the Mithril router.
In some cases, you want the pending route, that is, the route that
the router is currently resolving when you are trying to sync
up your app state to router behavior. The component in the above
example doesn’t have this problem because by the time it renders the
Mithril router has already deemed the route “resolved” so that
m.route.get()
and m.route.param()
deliver the latest
data.
Mithril has no gimme-the-pending-route API, and you can’t use
window.location
without tracking the router’s prefix (like
#!
)—which also lacks a getter. You can only set the prefix
using m.route.prefix()
. Mithril is basically clamming up so
that you have to solve your routing needs with your
state. The only way to reliably access and use the pending route for
tracking purposes is by writing RouteResolvers.
RouteResolvers: To block, or not to block?
When Mithril matches a route, it will start rendering once you give it
a component. In this
example, a Mithril component renders outright and redraws implicitly
as per the default behavior of m.request()
.
This approach is easy enough, but if the network fetch finishes
quickly, the loading indicator may look like an unpleasant flicker. To
fix this, the docs suggest blocking rendering using a RouteResolver,
which exposes the pending route as discussed earlier. But I won’t show
that here because I instead want you to understand how RouteResolvers
change the flow of route resolution. All you need to know is that
your pending route info is in the arguments to
the onmatch()
function (which fires when, guess what, the
associated route matches).
This revision
removes the flicker by removing the loading indicator entirely. The
documentation acts like the scorched-earth approach is a selling
point, but tell that to users with bad connections. So long as you
return a Promise, that onmatch()
can be used to delay rendering
until data is available for a component. Rejecting the promise makes
Mithril fall back to the default route. A more robust approach would
always allow for an indicator to account for slower connections while
preventing flicker. You can also use RouteResolvers to optimize layout
diffing using an
optional
render()
function. For now both techniques are left as an
exercise to the reader, but if you ask me, never block rendering and
be smart about loading indicators.
We now know enough to make a mess.
Introducing policy
Assume you are working on an admin dashboard for “tenants” that make widgets. We need:
- A widget listing page for one tenant. Authenticated users only.
- A widget detail page. Authenticated users only.
- An error page to display problems loading data, and
- A login page.
I call the decisions on how to navigate and handle related problems the routing policy because they are navigational requirements no matter what. Here’s a Mithril router configuration that meets these requirements.
m.route(root, '/login', {
'/login': Login,
'/error': ErrorReport,
'/:tenant/widgets': {
oninit() {
if (!app.user) {
m.route.set('/auth');
} else {
app.tenants[m.route.param('tenant')].loadWidgets().catch((err) => {
m.route.set('/error', null, {err});
});
}
},
view() {
const {widgets} = app.tenants[m.route.param('tenant')];
return (widgets)
? m(WidgetsOverview, widgets)
: m('p', 'Loading...');
},
},
'/:tenant/widgets/:id': {
onmatch({tenant, id}) {
if (!app.user) {
return Promise.reject();
}
return app.tenants[tenant].loadWidgetDetail(id)
.then((detail) => {
return {view: () => m(WidgetDetail, detail)};
})
.catch((err) => {
m.route.set('/error', null, {err});
});
},
},
});
The policy says that guests are redirected to /login
if they
request a private route. To do this, the route handlers do different
things under the assumption the app state is in scope.
- The component attached to
/:tenant/widgets
redirects the user inoninit()
- The RouteResolver attached to
/:tenant/widgets/id
returns a rejected Promise.
The latter case causes Mithril to fall back to the default route,
which is /login
in this case. This implicitly programs the
same behavior. You could still call m.route.set()
in the
RouteResolver and return undefined
assuming there isn’t a
render pass that would be surprised by that.
…Now.
I intentionally left out error handling and the origin of app
for brevity, but in the shoes of a junior who has never seen a router
or Mithril before, does this code make any goddamn sense?
Look at how much stuff we have to know just to write code that block guests. If one flow blocks rendering and the other doesn’t, our policy shouldn’t have to change shape to accommodate in this case.
You might (correctly) argue that this case is easy to refactor into a
more consistent form. Inconsistency in how we resolve routes is
not Mithril’s fault. However, Mithril does oblige us to
write our routing policy in its router’s terms. Once routing tables
get large and you have juniors on your team, keeping order will become
a chore. For example, to bring back a loading indicator in
/:tenant/widgets/:id
(the Product Team will ask for it; don’t act like
they won’t), you have to either:
- Replace the RouteResolver with a component to render that indicator immediately; or
- Make
onmatch
return a component immediately, out of sync with theloadWidgetDetail
call.
That’s not obvious to everyone. And if you are from the “pass state down as attrs” school of thought, you can get fucked because changing around Mithril’s routing code means you have to rewire how state circulates to your components (No, I will not import my app state in every JS module I write. That’s a different article). It’s easy for a team to end up in an awkward position where simple changes lead to non-trivial refactor jobs.
Let’s make this more interesting: What do you do if you want several loading indicators starting with a big spinner, then a dashboard breakdown with a bunch of little baby spinners on analytics widgets crunching numbers? The answer is not worth thinking about, but I can tell you my first attempt involved an unholy union of decorated RouteResolvers.
Organizing code around policy
Again, the struggles with Mithril’s router is not a statement on its quality. However, something is backwards. Our routing policy should not depend on Mithril’s router. The opposite must be true to clean up our code. From the Bob Martin school of thought, it is the rules that make an app work that should sit at the center of your software. While we cannot physically force Mithril to depend on our code, we can reorganize to say that the rules of our app dictate everything from routing, to state, to views, and so on.
After writing and rewriting my routing code several times to do what should be simple things, I asked myself what I wanted my code to look like.
For aforementioned reasons I knew that the code I wanted would have to meet the following requirements:
- The code says what it does and does what it says.
- You should be able to change routing policy without needing 6 candles, a virgin’s sharpened femur, and a sheep.
- Application state is more authoritative than the router’s state.
- Never block rendering. Views render the app at any moment. If the views look wrong, I either changed state wrong or redrew at a bad time.
- The Mithril components are pure (attrs return content deterministically).
I came up with this:
dispatch(document.getElementById('root'), '/error', app, {
'/': () => Login,
'/login': () => Login,
'/error': () => ErrorReport,
'/:tenant/widgets': loggedin((app, task) => {
const {tenant} = app.spaRequest;
task(() =>
app.selectTenant(tenant)
.then(() => app.loadWidgetListing()))
return WidgetsOverview;
}),
'/:tenant/widgets/:id': loggedin((app, task) => {
const {id, tenant} = app.spaRequest;
task(() =>
app.selectTenant(tenant)
.then(() => app.loadWidget(id)));
return WidgetDetail;
}),
});
dispatch()
also sets up the Mithril router, but you have to
interpret the setup differently. It assumes that all state comes from
one place, a la Redux. That’s where you see app
passed in. It
can be a plain old empty object if you want. It also assumes that the
pending route and all related arguments are part of application state,
and makes it available as part of app.spaRequest
.
Second, the /error
route specified is not just a default
route. It is considered the route you visit in error conditions, and I
treat it that way for the same reason I would write
try..catch
in an entry point.
Finally, the routing table itself is a table of routes to
functions. The functions return components to render
synchronously but may change state asynchronously in terms
of the pending route using a task()
function. The task
function takes a function that returns a Promise and redirects to the
error route in the event of an unhandled failure. Otherwise, it starts
a new render pass when the task finishes.
That loggedin()
function you see is just an app-specific
decorator that redirects users to /login
if an authenticated
user is not in app state. You will see it in the below CodePen. I want
you to see that as an example of why it should be easy to
express routing policy. Previously policy had to be parsed from
Mithril semantics, but in this case I could remove Mithril from my
project entirely and not violate the rules as governed by this
approach. If you want to guard a route from unauthenticated users,
slap loggedin
on it. If you want to add a colony of nested
loading indicators, do that in your component, then run task
calls that iteratively update state with data in stages.
Some might also want to factor out the “authenticated tenant selection” pattern in the handlers for cleanliness. That is now an easy change, and you can clearly see what guarantees are made about state with function decorators, even if the flow is highly subjective.
function tenantRoute(fn) {
return loggedin((app, task) => {
const tenantTask = (next) => task(() =>
app.selectTenant(app.spaRequest.tenant).then(next));
return fn(app, tenantTask);
});
}
dispatch(document.getElementById('root'), '/error', app, {
'/': () => Login,
'/login': () => Login,
'/error': () => ErrorReport,
'/:tenant/widgets': tenantRoute((app, tenantTask) => {
tenantTask(() => app.loadWidgetListing()))
return WidgetsOverview;
}),
'/:tenant/widgets/:id': tenantRoute((app, tenantTask) => {
tenantTask(() => app.loadWidget(app.spaRequest.id)));
return WidgetDetail;
}),
});
This approach is not without caveats. For one, you are obliged to
write LBYL-style components that
check for incomplete state to render one of many possible variations
of a view. Second, you have to be extra careful to
synchronize state between redraws. This should go without saying, but
the approach in dispatch()
makes it you to spawn concurrent
Promise chains that all mutate state. But those of a Redux mindset
would see an opportunity to extend task()
with the
transactional behavior expected from reducers.
Demo, plus other considerations
If you want to see an example of dispatch()
in action, play
with this
CodePen.
The CodePen contains the transpiled demo from the mithril-dispatch
GitHub project, which uses a routing policy based on a real-world
SaaS shop despite the simplistic content. I encourage you to
start
reading here to see the code organization benefits brought by
dispatch()
. As a bonus, the demo also shows a
highly-opinionated use of layout diffing optimization for users
familiar with RouteResolver.render()
. You can also see that
the components end up pure
despite complex, slow operations that would involve heavy use of
lifecycle methods otherwise.
Conclusion
In this article I have introduced you to SPA routers. From there, we
learned about Mithril’s router and how it handles different policy
decisions when routing. We learned that changing the routing policy to
meet simple requirements is harder than just expressing the policy in
plain language. By adding a dispatch()
function to separate
policy from Mithril semantics, the Mithril components we write only
render what they are given, state concerns itself with the data, and
route resolution merely connects the two together.
I also emphasized that more than anything, the rules of your
app are king. Just because I wrote dispatch()
in the manner
shown does not mean I will use that exact implementation later. Just
be sure that you do not make your rules depend on Mithril’s router, or
even Mithril in general. Frameworks are meant to enable your
productivity, not tell you how to code.
I love coding and have done so professionally for over a decade. I use what little free time I have to experiment on ideas of various quality and to help others write quality software. If this post was helpful to you, please leave a tip and share this article with anyone that need to tame the Mithril router.