Elm-Style State
nano-ui-emit is the reducer-based alternative to Var: the view emits typed messages, and a pure reducer folds them into a model.
data Model = Model {counter :: Int, name :: Text}
deriving Eq
data Msg = Increment | Decrement | SetName Text
view :: Model -> NanoUIE Msg ()
view m = column $ do
heading "Profile"
row $ do
emitWhen (button "-") Decrement
label (T.pack (show m.counter))
emitWhen (button "+") Increment
emitChanged textInput m.name SetName
update :: Msg -> Model -> Model
update Increment m = m {counter = m.counter + 1}
update Decrement m = m {counter = m.counter - 1}
update (SetName n) m = m {name = n}
main :: IO ()
main = runSdlAppReduce defaultSdlOptions update (Model 0 "Ada") view
Widgets, layout, focus, and keys are unchanged. Only the state model differs.
Emitting
NanoUIE msg a is a UI action that can emit only msg. There is no Typeable constraint and no runtime type test: emitting the wrong type is a compile error.
A NanoUIE view is an ordinary NanoUI action with the view’s message sink in scope (an implicit parameter, ?emit). Every core widget runs in it unchanged, so NanoUI.Emit re-exports NanoUI and adds the functions below. An emit inside a container, an overlay body, a tab or a menu callback goes to the sink of the view around it.
| Function | Emits when |
|---|---|
emit msg | Immediately |
emitWhen widget msg | The widget returns True (button click, submitted field) |
emitChanged widget old toMsg | A Var a -> NanoUI r widget’s final value differs from old |
emitEdited widget old toMsg | The widget’s value changed and respChanged is true |
settingsForm :: Float -> NanoUIE Msg ()
settingsForm volume =
column $ do
label "Settings"
withKey ("form" :: Text) $ do
emitChanged (slider 0 100) volume SetVolume
emitWhen (button "Save") Save
emitChanged runs the widget against a temporary Var seeded with the model value and emits the new value only if it differs. emitEdited is its stricter form for text fields, where programmatic writes should not look like edits.
Give each view a signature in its own message type, view :: Model -> NanoUIE Msg (). The runners take a view of that shape, and a local helper defined outside a view needs the signature too, or GHC finds no sink for its emit.
Caution
Emit only inside an event branch.
emit Saveat the top level of a view emits on every pass, and the reducer runs every message. The adapters above already guard on the widget’s event.
Messages are collected across every pass of a frame, including local-Var rebuilds, and reduced at frame end in emission order.
Components
mapMessages embeds a child view whose message type is smaller than its parent’s:
data ChildMsg = ChildIncrement | ChildReset
data ParentMsg = Child ChildMsg | SaveAll
childView :: Int -> NanoUIE ChildMsg ()
childView n = row $ do
emitWhen (button "+") ChildIncrement
label (T.pack (show n))
emitWhen (button "Reset") ChildReset
parentView :: Int -> NanoUIE ParentMsg ()
parentView n = column $ do
mapMessages Child (childView n)
emitWhen (button "Save All") SaveAll
Messages go straight to the parent’s sink in traversal order, with no intermediate queue. mapMessages changes only the message type and leaves widget ids unchanged, so a list still needs withKey around each component, exactly as a Var-based row does.
For local UI state, keep using Var inside the component; the durable model stays in NanoUIE.
Running and testing
runSdlAppReduce and runRgfwAppReduce take an update function, an initial model, and a view:
main :: IO ()
main = runSdlAppReduce defaultSdlOptions update initialModel view
The view runs with the current model, messages are reduced at frame end, and the drawing shown for that frame is from before the reduction. If the model changed, another frame is requested to draw it.
For tests and embedded uses:
| Function | Returns |
|---|---|
runNanoUIE view | NanoUI (result, [msg]) |
runFrameE ctx input view | IO (result, [msg], DrawData, Bool) |
runFrameReduce update ctx input model view | IO (result, model, [msg], DrawData, Bool) |
runClickReduce update ctx input model view pos | IO (model, [msg], Bool) |
runFrameReduce requests a follow-up frame when the reduced model differs. reduceMessages and reduceUpdates fold message lists.
When to use it
Var-first | Elm-style | |
|---|---|---|
| State shape | Distributed through the view and app record | One model value |
| Transitions | Direct mutation | Explicit Msg and one update |
| Testing | Widget-level with NanoUI.Testing | Pure update tests plus frame tests |
| Local widget state | Natural | Still use Var for drafts and open flags |
| Best for | Tools and editors with external state | State machines, wizards, undo/history, small apps with a clear model |
The styles mix. An emitting view can keep Vars for transient UI state; a Var-based view can run a small component with runNanoUIE and act on the returned messages.
Keep update pure: file and network I/O belong in the view’s event branch, or in an effect queue run outside the reducer. Do not mirror model state in Vars to read it a frame earlier; keep durable state in the model only.