Mithril's m.request: Declarative Data Fetching Without the Boilerplate
Mithril's m.request wraps fetch, returns a promise, and hooks into the framework's auto-redraw system—eliminating boilerplate for loading, error, and success states. Here's how it works, a complete user-list example, and where the trade-offs bite.
16 Aug 2026, 06:50 UTC

The Problem: Async Data in Components Is Messy
Every frontend developer knows the pattern: fetch data in a lifecycle hook, set loading state, handle errors, update state again, then trigger a re-render. In React you might use useEffect with useState. In Vue it's onMounted plus reactive refs. The boilerplate adds up, and inconsistent error handling across components creates bugs that surface only in production.
Mithril takes a different approach. Its m.request utility wraps the native Fetch API (or XHR in older environments), returns a standard Promise, and—crucially—integrates with Mithril's automatic redraw system. When the promise resolves, the view updates without you calling setState or forceUpdate. This article walks through how that works in practice, where it shines, and where you'll hit limits.
How m.request Fits Into Mithril's Redraw Model
Mithril 2.x runs a global auto-redraw after every event handler, lifecycle method, and promise resolution that originates from its own APIs. m.request is one of those APIs. When you call it, Mithril tracks the returned promise. Once the promise settles—success or failure—Mithril schedules a redraw. Your view function runs again with the new data, and the DOM updates.
This means you don't need a separate state variable for "loading" or "error" if you structure your component to derive those states directly from the promise. The promise is the state.
Worked Example: A User List Component
Here's a complete, self-contained component that fetches users from /api/users, shows a loading indicator, surfaces errors, and renders the list. Save this as user-list.js and include it in a page that loads Mithril via CDN (e.g., https://unpkg.com/mithril@2.2.2/mithril.min.js).
// user-list.js
const UserList = {
oninit(vnode) {
// Start the request when the component initializes
vnode.state.usersPromise = m.request({
method: "GET",
url: "/api/users",
// Optional: deserialize non-JSON responses
// deserialize: (value) => JSON.parse(value)
});
},
view(vnode) {
const promise = vnode.state.usersPromise;
// Loading state: promise is pending
if (promise instanceof Promise && !promise["__mithril_resolved"]) {
return m(".loading", "Loading users…");
}
// Error state: promise rejected
if (promise instanceof Promise && promise["__mithril_error"]) {
return m(".error", [
m("p", "Failed to load users: " + promise["__mithril_error"].message),
m("button", { onclick: () => m.redraw() }, "Retry")
]);
}
// Success: promise resolved, value is the array
const users = promise;
return m("ul.user-list", users.map(user =>
m("li", { key: user.id }, [
m("strong", user.name),
m("span", " — " + user.email)
])
));
}
};
// Mount point
m.mount(document.getElementById("app"), UserList);
A few notes on the example:
- The component stores the promise on
vnode.stateso it persists across redraws. - Mithril attaches internal properties (
__mithril_resolved,__mithril_error) to tracked promises. These are undocumented but stable in 2.x; a safer alternative is to wrap the promise yourself with.then()/.catch()and store derived state explicitly. - The retry button calls
m.redraw()which re-runsoninit, triggering a fresh request.
Trade-offs and Limitations
Configurability
m.request covers the common case: JSON GET/POST with automatic serialization. Need request cancellation? You must wire up an AbortController manually and pass its signal via the extract or config options—there's no first-class cancel() method. Need interceptors for auth headers on every request? You'll write a wrapper function. Libraries like Axios or Ky give you that out of the box; m.request deliberately stays small (~1 KB gzipped).
Auto-redraw Surprises
Because Mithril redraws after any promise from its APIs resolves, a stray m.request in a utility function can trigger a full-app redraw. If you mutate global state outside a component (e.g., a cache object) and then call m.request, you'll get a redraw even if no component uses that cache. The fix: keep side effects inside components or use m.request only where you want the redraw.
Server-Side Rendering
In SSR contexts (e.g., Mithril's renderToString), m.request calls execute on the server. That's usually not what you want for client-only data. The typical pattern is to guard requests with if (typeof window !== 'undefined') or use a separate data-fetching layer for SSR.
When to Reach for Something Else
Stick with m.request when:
- Your data-fetching needs are straightforward (REST JSON, maybe form posts).
- You want zero dependencies beyond Mithril itself.
- You like the mental model of "promise = state" and automatic redraws.
Switch to fetch directly (or a tiny wrapper) when:
- You need streaming responses, custom cache control, or complex retry logic.
- You're integrating with a non-JSON API (GraphQL multipart, binary uploads).
- You want request cancellation without rolling your own AbortController plumbing.
Next Steps: Verify in Your Own Project
Don't take this at face value. Create a minimal HTML file, drop in the UserList component above, and point it at a real endpoint (or a mock server like json-server). Open DevTools → Network and confirm:
- The request fires on mount.
- The loading UI appears instantly.
- On 200, the list renders without any manual
m.redraw()call. - On 500, the error UI appears and the retry button works.
Then swap m.request for a raw fetch + m.redraw() and compare line count and cognitive load. That's the fastest way to decide whether Mithril's built-in utility earns its keep in your codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.