Native (mobile)
Main-thread events
Thread affinity is per-handler, not per-event-name. Any given event can be handled on either thread; the choice is made at each handler, so the same event (say tap) may run on the BTS for one element and the MTS for another. The default is the BTS — a plain onTap handler runs on the background thread. Opting a handler into the MTS is explicit; nothing runs on the main thread unless you ask for it.
By default an event handler runs on the BTS: the event is forwarded from the MTS, update runs on the BTS, the model changes, and the resulting diff is shipped back to the MTS to paint. That round-trip is fine for most interactions but adds latency for gesture- and scroll-linked animation.
For those cases, handlers have *Main-suffixed variants (e.g. onTapMain, onTouchMoveMain) that run synchronously on the MTS — no VDOM diff, no patches, no BTS round-trip. Such a handler is imperative: it mutates the target element directly through the helpers in Miso.Native.MainThread (e.g. setStyleProperty). The *MainWith variants additionally hand the handler the current model and the target DOMRef.
Because a main-thread handler must be reconstructed on the MTS, it is an EventHandler embedded with event . static — so main-thread event handlers require StaticPointers:
{-# LANGUAGE StaticPointers #-}
view _ _ _ =
view_ [ event (static (onTapMain HandleTap)) ] []The same static capture limitation applies: an onTapMain handler refers to a top-level action / function; runtime data reaches the handler via the decoded event payload, not a captured closure.
The generic primitives: on and onMain
The per-element on* / on*Main helpers are sugar over two combinators, and the same (eventName, decoder, toAction) works with either — that is how one event is captured on whichever thread you choose, per handler:
on name decoder toAction→ a plainAttributethat runs on the BTS. Nostatic: a background handler is reconstructed nowhere else, so it may close over the enclosingview.onMain name decoder toAction→ anEventHandlerthat runs on the MTS, embedded withevent . static.
-- same `tap` event, one handler per thread:
view_ [ on "tap" emptyDecoder (\_ _ _ -> Grow) ] children -- BTS
view_ [ event (static (onMain "tap" emptyDecoder onTapMain)) ] children -- MTSThe Attribute-versus-EventHandler+static split is the mechanism: only the main-thread handler has to cross to the MTS by StaticKey, which is why onMain (and every *Main helper) needs StaticPointers while on does not. onMainWithOptions exposes Phase / Options for the MTS variant, mirroring onWithOptions.
Reaching the model (and why it is passed, not captured)
A static main-thread handler cannot close over the model, props or context from the enclosing view — those are local bindings, which static forbids. So rather than capture them, the *MainWith variants pass the model as an argument to the handler, giving imperative MTS code the state it needs without a BTS round-trip. Note this is the main thread's own copy of the model: it is populated on the MTS eventually consistently from the BTS, so a handler may observe a value slightly behind the latest BTS state.
Props and context are not on the main thread
Unlike the model, a component's props and the app-global context are not mirrored to the MTS at all (matching ReactLynx, where React state is background-thread-only). They live solely on the BTS; the MTS keeps only its boot values, so getProps / getContext inside a main-thread handler would read stale data. If a main-thread handler needs a prop or context value, fold it into the model or carry it in the dispatched action payload.
Ownership caveat
A property you drive imperatively from the MTS must not also be written declaratively by the BTS view for the same element: both threads write the shared element tree through the same PAPI with no arbitration, so one will clobber the other. Keep a single owner per (element, property) — typically compositor properties like transform / opacity that the view leaves alone.