Local reactive state in Mithril with m.stream
Use Mithril's built-in m.stream for local component state to avoid external stores. Learn the API, redraw integration, a debounced search pattern with cleanup, and when streams are not enough.
13 Mar 2026, 21:15 UTC

The problem: reactive local state without pulling in a store
Small to medium Mithril single‑page applications often need a few reactive values per component: a search query, a loading flag, a selected item. Adding a full external state library adds bundle weight and indirect plumbing for state that never leaves the component. Mithril ships a built‑in primitive for this case: m.stream.
What m.stream actually is
m.stream is a minimal reactive cell. It holds a single value and notifies subscribers when the value changes.
// create a stream with initial value
const count = m.stream(0);
// read current value
count(); // → 0
// update value, notifies subscribers
count(1);
A stream is also a function. Calling it with no argument reads, calling with an argument writes. Subscribers are functions registered via the stream itself.
How streams hook into redraw and routing
Mithril batches view updates through a scheduled redraw. Reading a stream inside a view function makes the view declarative: the view returns markup based on stream() at render time.
When a stream is updated, you can trigger a redraw. Mithril coalesces multiple updates into one DOM pass to avoid layout thrash. In the browser redraw is asynchronous; in test environments it can appear synchronous. Do not assume immediate DOM mutation after stream(newValue).
With routing, a component can create its streams in oninit and dispose them in onremove. Using stream.end() in onremove prevents leaked subscriptions when the route changes and the component is torn down.
A worked pattern: debounced search with cleanup
A common local concern is debouncing user input and cancelling in‑flight requests when the component unmounts.
m.mount(document.body, {
oninit(vnode) {
vnode.state.query = m.stream('');
vnode.state.results = m.stream([]);
vnode.state.loading = m.stream(false);
const debounced = vnode.state.query.map(q => q).map(q => {
// return a stream that will be used for side effects
return q;
});
let timer;
const unsub = debounced(function(q){
clearTimeout(timer);
vnode.state.loading(true);
timer = setTimeout(() => {
m.request({method:'GET', url:'/search?q='+encodeURIComponent(q)})
.then(res => { vnode.state.results(res); vnode.state.loading(false); })
.catch(() => vnode.state.loading(false));
}, 300);
});
vnode.state.dispose = () => {
clearTimeout(timer);
unsub();
vnode.state.query.end();
vnode.state.results.end();
vnode.state.loading.end();
};
},
onremove(vnode){ vnode.state.dispose && vnode.state.dispose(); },
view(vnode){
const s = vnode.state;
return m('input[type=text]', {
value: s.query(),
oninput: e => s.query(e.target.value)
}) ,
s.loading() ? m('p','Loading...') : null,
m('ul', s.results().map(r => m('li', r)))
}
});
The view reads s.query(), s.loading() and s.results() directly. Updating s.query triggers the mapped stream, which schedules a debounced request. Cleanup via stream.end() and unsub() prevents updates after unmount.
A practical way to check behavior is to create a minimal HTML page with Mithril loaded locally (e.g. <script src="https://unpkg.com/mithril/mithril.js">), define a stream, subscribe with console.log, update the value and observe that subscribers fire and the view re‑renders. Open the page in a browser, type in the input field, wait for the debounced request, and inspect the console for logged values.
Trade‑offs and limitations
Streams are not a full store. There is no built‑in time‑travel debugging, middleware, selector memoization, or devtools support out of the box. State is explicit and local, which simplifies reasoning in small apps but can become fragmented as cross‑component sharing grows.
Using streams for local component state is a practical engineering decision to keep bundle size low and avoid external dependencies. State stays co‑located with components and updates are explicit.
When you need shared, normalized state, undo/redo, or a predictable update pipeline across many routes, an external library will provide structure that streams alone do not.
Actionable closing
Start with m.stream for per‑component reactive values. Create streams in oninit, read them in view, and dispose with stream.end() in onremove. Use stream.map and stream.combine for derived values, and pair updates with m.redraw when needed outside the view.
Audit a component for shared state needs. If the state never leaves the component, keep it as a stream. If it does, evaluate whether the cost of an external store is justified before adding it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.