Mithril's m() Function: Building Efficient UIs Without JSX
Learn how Mithril's hyperscript m() function simplifies UI development through a functional approach to virtual DOM construction, with practical examples and performance considerations.
26 Sept 2026, 17:58 UTC

The Problem: Reactive UIs Without a Build Step
Small and medium web applications often get saddled with a JSX transpilation pipeline before the first feature ships. The build step adds toolchain complexity, slows iteration, and produces larger bundles than the source code alone. Mithril takes a different path: its m() hyperscript function builds virtual DOM nodes directly in plain JavaScript, so the browser evaluates your view code without an intermediate compilation step.
The practical takeaway is that you can ship interactive interfaces with a single script tag, but you must understand exactly when Mithril redraws the screen and when you need to trigger updates yourself.
How m() Builds Virtual DOM Trees
The m() function accepts a CSS-style selector, an optional attributes object, and children. It returns a lightweight virtual DOM node—a plain JavaScript object describing the element. Mithril diffs this object against the previous tree and applies only the necessary DOM mutations.
// Element with attributes and children
m('div.card', { id: 'main' }, [
m('h1', 'Dashboard'),
m('button', { class: 'btn', onclick: handleClick }, 'Refresh')
])
Because the syntax is plain JavaScript, editors and linters treat it like any other function call. There is no JSX transform to configure, but that also means malformed structures—such as passing an object where a string is expected—surface at runtime rather than at compile time.
State, Auto-Redrawing, and the Event Loop
Mithril manages state through ordinary JavaScript objects. Its auto-redrawing system hooks into the DOM event loop: after most event handlers run, Mithril automatically re-renders the affected components. You do not call a render function or dispatch actions to update the screen.
Consider a counter that increments on a button click:
const state = { count: 0 }
const Counter = {
view: () => m('div', [
m('span', state.count),
m('button', {
onclick: () => { state.count++ }
}, 'Increment')
])
}
m.mount(document.body, Counter)
Notice there is no explicit redraw call inside onclick. Because the handler is a standard DOM event listener, Mithril detects the interaction and redraws automatically. Adding m.redraw() here is redundant.
When Auto-Redraw Fails: Async Boundaries
Auto-redrawing only covers events that pass through Mithril's event system. State changes that originate outside that loop—inside setTimeout, fetch callbacks, or WebSocket message handlers—will not trigger a redraw on their own. In those cases you must call m.redraw() manually.
const state = { data: null }
const Loader = {
oninit: () => {
fetch('/api/status')
.then(r => r.json())
.then(json => {
state.data = json
m.redraw() // Required: fetch resolves outside the event loop
})
},
view: () => state.data
? m('pre', JSON.stringify(state.data, null, 2))
: m('p', 'Loading...')
}
This distinction is the most common source of "my view did not update" bugs in Mithril. If a DOM event caused the change, let auto-redraw handle it. If a timer, network response, or external store caused it, call m.redraw().
Component Lifecycle Hooks
Components are plain objects with a view function and optional lifecycle hooks. These hooks let you run side effects at predictable moments.
| Hook | Triggered When |
|---|---|
oninit |
Component is initialized, before DOM exists |
oncreate |
DOM element is created and attached |
onupdate |
DOM element is updated after a redraw |
onremove |
Element is about to be removed |
Use oninit for data fetching and subscriptions. Use oncreate when you need to measure the DOM or initialize a third-party widget. Use onremove to clean up listeners and prevent leaks.
Worked Example: A Todo List
The following component keeps a list of todos in a plain object. Event handlers mutate state directly; Mithril auto-redraws after each interaction.
const state = {
todos: [],
draft: ''
}
const TodoApp = {
view: () => m('main', [
m('h1', 'Todos'),
m('input', {
placeholder: 'What needs doing?',
value: state.draft,
oninput: e => { state.draft = e.target.value }
}),
m('button', {
onclick: () => {
if (state.draft.trim()) {
state.todos.push({ text: state.draft, done: false })
state.draft = ''
}
}
}, 'Add'),
m('ul', state.todos.map((todo, i) =>
m('li', { key: i }, [
m('input', {
type: 'checkbox',
checked: todo.done,
onchange: () => { todo.done = !todo.done }
}),
m('span', { style: { textDecoration: todo.done ? 'line-through' : 'none' } },
todo.text)
])
))
])
}
m.mount(document.body, TodoApp)
Every state mutation above happens inside a DOM event handler, so no manual redraw calls are needed. The key attribute on list items helps Mithril track identity across renders.
Trade-offs and Limitations
Mithril's simplicity carries costs. Without a compilation step, there is no static analysis of your view tree: a typo in a tag name or a malformed attributes object will not be caught until the code executes in a browser. For large teams, this shifts error detection from build time to runtime.
Performance is another consideration. Auto-redrawing diffs the entire component tree on every event. For most applications this is fast enough, but screens with extremely high-frequency updates—such as a real-time chart receiving hundreds of messages per second—may benefit from throttling input, batching state changes, or scoping redraws to smaller components.
Bundle size is Mithril's traditional advantage. Because there is no JSX transform, the runtime is small relative to toolchain-heavy alternatives. You can verify this locally by comparing the network transfer size of a Mithril script tag against a bundled React application that includes a transpiler and reconciler.
Verifying Your Setup
To confirm the behavior described above, run these checks in a browser console after loading Mithril (version 2.x assumed):
- Render a basic node:
m.render(document.body, m('div', 'Check')). The text should appear immediately. - Mount the counter example and click the button several times. The number should increment without any explicit
m.redraw()call. - Wrap a state change in
setTimeoutwithout callingm.redraw(). The view should stay stale, proving that async boundaries require manual redraws. - Add
m.redraw()inside the timeout callback. The view should now update, confirming the fix. - Compare the network transfer size of the Mithril script against a bundled React/JSX equivalent to confirm the absence of a transpilation requirement.
These checks validate both the auto-redraw path and the manual path, which together cover the vast majority of Mithril state synchronization scenarios.
Bottom Line
Mithril's m() function removes the JSX build step while keeping a virtual DOM and a predictable component model. The engineering decision that matters most is not the syntax itself, but respecting the boundary between Mithril's event loop and the rest of your asynchronous code. Let auto-redraw handle DOM events, and reserve m.redraw() for timers, network callbacks, and external state sources.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.