Designing a Mithril SPA Around Its Router and Auto-Redraw, Not Around Extra Libraries
An architecture note on building a Mithril SPA with just m.route resolvers and the auto-redraw system: requirements, trust boundaries, failure modes, and verification checks.
10 Jul 2026, 05:34 UTC

If you picked Mithril for its size — routing, XHR, and rendering in roughly 8–10 KB gzipped — the most common design mistake is immediately adding a state library and a third-party router on top. Mithril's own m.route with route resolvers, plus its auto-redraw system, cover most single-page-app needs. This note lays out the requirements, the smallest design that meets them, where the trust boundaries sit, and how to verify the whole thing behaves.
Requirements
A typical small SPA needs: URL-driven views with deep-linking, data fetched before a view renders, redirects when prerequisites fail (e.g., no auth token), UI that stays in sync with state changes, and safe rendering of untrusted strings. The assumption here is Mithril 2.x; redraw triggers and m.request options differ from 1.x, so check your lockfile before relying on specifics.
The smallest suitable design
Use m.route with resolver objects instead of plain components. A resolver has onmatch (runs before render; fetch and validate here) and render (returns the vnode tree). This puts per-route data requirements in one auditable place:
// Runs in the browser entry point, after login state is established.
// m.route.set performs a redirect; returning nothing from onmatch
// means the target component never mounts.
var UserPage = {
onmatch: function(args) {
var id = parseInt(args.id, 10);
if (!isLoggedIn()) {
m.route.set("/login"); // redirect, no render
return;
}
if (Number.isNaN(id)) {
m.route.set("/not-found");
return;
}
return m.request({ url: "/api/users/" + id })
.catch(function() { m.route.set("/error"); });
},
render: function(vnode) {
return m("h1", "User " + vnode.attrs.id);
}
};
m.route(document.body, "/users/1", {
"/users/:id": UserPage,
"/login": LoginPage,
"/not-found": NotFoundPage,
"/error": ErrorPage
});No store library: keep shared state in a plain module-level object and mutate it inside Mithril event handlers or m.request callbacks, where redraw is automatic.
Trust and data boundaries
Everything user-controlled enters through three doors: route parameters, the query string (both read via m.route.param()), and request bodies. Route params are strings, always — parse and validate them in onmatch before they touch an API call or the view, as in the parseInt check above.
On output, Mithril escapes text in vnodes by default, so m("p", userInput) is safe against script injection. The escape hatch is m.trust(html), which injects raw HTML verbatim. Treat every m.trust call as a code-review audit point: it should only wrap markup that is sanitized or fully server-controlled, never API or user content.
The auto-redraw contract
Mithril re-renders after its own wrapped event handlers, route changes, and resolved m.request promises. State mutated outside those contexts — a raw setTimeout, a WebSocket callback, a third-party library event — will not trigger a redraw, and the UI silently goes stale. The fix is an explicit m.redraw() after the mutation. Make this a convention: any module that owns an external callback is responsible for calling m.redraw() when it changes shared state.
Operational checks
Because rendering is synchronous, you can test without a browser harness: call a component's view() directly with crafted state and assert on the vnode tree. For routes, set m.route.set(...) in a test page and assert what rendered. Two quick manual checks worth doing once per project:
- Log in
onmatchand confirm the target component'soncreatenever fires when the resolver redirects. - Mutate state in a plain
setTimeoutwithoutm.redraw()and confirm the view does not update; addm.redraw()and confirm it does.
Failure modes
- Rejected onmatch promise: the route hangs unresolved. Always
.catchand redirect to an error route. - m.request failures: Mithril does not retry; handle errors explicitly per request.
- Routing prefix: history-API mode (
m.route.prefix("")) needs a server fallback toindex.html, or refreshes on nested routes 404. Hash mode avoids this but changes URL structure. - Stale UI: almost always a missed
m.redraw()around a non-Mithril callback.
When this design stops fitting
Add a real state container when many unrelated components mutate shared state and redraw scoping becomes hard to reason about. Add a data layer when request caching/dedup logic starts duplicating across resolvers. Until then, the framework you already shipped is enough.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.