Tame Mithril's router to write clearer apps

I moved to 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 routerand 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:

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 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 (Product will ask for it; don't act like they won't), you have to either:

  1. Replace the RouteResolver with a component to render that indicator immediately; or
  2. Make onmatch return a component immediately, out of sync with the loadWidgetDetail 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, with this in mind.

For aforementioned reasons I knew that the code I wanted would have to meet the following requirements:

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.