The “I’m stupid” elm language nugget #14

I made a global message bus using Cmds and Tasks against everyone’s advice. It sucked. The prevailing advice against this was right.

The good news is that exists and is easy to use. It’s easy to convert to as well. I’m going to give a fairly wide overview of the process, and give some commentary.

TLDR: You should not use the effect system to send messages down the hierarchy and you should avoid message broadcast where it might otherwise be needed, instead having a data-only global state object that can be imported by every component that needs to observe it.

First, the problem: leaves produce messages that should change global state, and act based on global state.

Say we have:

State graph of an app that might use broadcast messages

Imagining that Playlist holds <audio> elements, and both Queue and PodcastDisplay hold FakePlayer objects, one can imagine that an app with this structure would allow audio players to exist as a kind of global app state, and have visual standins for the playing state as ephemeral parts of the UI.

Converting to outmessage from elm-return is a pretty close match:

-update : ST.State -> Msg -> Model -> (Model, Cmd Msg)
+update : ST.STate -> Msg -> Model -> (Model, Cmd Msg, List MessageBus.Msg)

Passing down bus messages is pretty natural, and the code is quite similar to code using elm-return:

import OutMessage as OutMsg
setPlayer : Uri -> Model -> FakePlayer.Model -> Model
setPlayer u model player =
{ model | players = Dict.insert u.uri player model.players }
applyPlayerMsg :
ST.State -> Uri -> FakePlayer.Msg -> Model ->
(Model, Cmd Msg, List MessageBus.Msg)
applyPlayerMsg state u m model =
|> Dict.get u.uri
(FakePlayer.update state m
>> OutMsg.mapCmd (PlayerMsg u)
>> OutMsg.mapComponent (setPlayer u model)
|> Maybe.withDefault (model ! [] !! [])

OutMessage’s mapCmd and mapComponent pass through the message bus. I defined an operator called (!!) which works like elm’s own (!) operator but in my case, adds a list of bus messages to a normal state, effect tuple:

(!!) : (model, Cmd msg) -> List bus -> (model, Cmd msg, List bus)
(!!) (mod,eff) bus = (mod, eff, bus)

elm-return has an andThen function, which takes a function yielding effects and combines them with the old effects:

andThen : (a -> Return msg b) -> Return msg a -> Return msg b

OutMessage doesn’t have an analog since it’s unopinionated about what the type of the bus should be, and has some support for Maybe, Result and List. While that’s nice, this is a function you’ll definitely want if your bus type is some kind of monoid:

andThen :
(model -> (model, Cmd msg, List bus)) ->
(model, Cmd msg, List bus) ->
(model, Cmd msg, List bus)
andThen mapf (model, cmds, buss) =
let (newm, newcmd, newbus) = mapf model in
(newm, Cmd.batch [cmds, newcmd], buss ++ newbus)

And it works nicely, allowing us to compose update functions that add cmd or bus traffic:

BusMsg (MessageBus.ExpandPodcast uri guid rect) ->
urlUpdate state (Router.Detail guid) { model | animRect = rect }
|> RE.andThen
((flip (!)) [after 0.01 DetailAnimate]
>> (flip (!!)) [MessageBus.LoadFeed uri guid]

At the top of the model, I just followed the example for evaluateList in the OutMessage docs. To close the loop here, one could enqueue the bus messages or repeatedly process them until they quiesce. Note that this changes the order in which your message bus is processed, so your needs might favor either. For my use, there are no dependent bus messages, so there isn’t any difference.

The case where the messages are enqueued emulates a breadth-first-search approach where you can think of each volley of bus messages as a different “generation” and each generation’s messages are all processed before the next.

-- Just enqueue the bus messages and let the runtime handle it.
passBusMsg : MessageBus.Msg -> Model -> (Model, Cmd Msg)
passBusMsg m model =
model ! [RE.send (BusMsg m)]

The case where the messages are handled recursively is a depth-first-search in which any bus traffic generated from a bus message is handled right away, before the next bus message.

-- Run the bus messages until there are no more global state changes
-- to announce.
quiesceBusMessages :
(Msg -> Model -> (Model, Cmd Msg, List MessageBus.Msg)) ->
MessageBus.Msg -> Model -> (Model, Cmd Msg)
quiesceBusMessages update msg model =
case update (BusMsg msg) model of
(newmodel, newcmds, []) -> (newmodel, newcmds)
(newmodel, newcmds, newbus) as updates ->
OutMsg.evaluateList (quiesceBusMessages update) updates

Joined with the standard elm architecture like this:

(.pathname >> Router.parse >> MessageBus.ChangeView FromUrl >> BusMsg)
{ init =
(\f l ->
let state = ST.init in
appInit state f l
|> OutMsg.mapComponent
(\a ->
{ app = a
, youtube = YT.init
, state = state
|> OutMsg.evaluateList (quiesceBusMessages appUpdate)
, update =
(\msg model ->
appUpdate msg model
|> OutMsg.evaluateList (quiesceBusMessages appUpdate)

And then state can handle bus messages and update itself:

update :
MessageBus.Msg -> State ->
(State, Cmd MessageBus.Msg, List MessageBus.Msg)
update msg model =
case msg of
MessageBus.ClickPlay u ->
{ model | playing = Just u } ! [] !! []
MessageBus.ClickPause u ->
let playing =
if (Just u) == model.playing then
(model |> setPlaying playing) ! [] !! []
_ -> model ! [] !! []

It can seem like a small sin overall to wire in child to parent communication in other ways, but it was surprising how much snappier my app was when I rooted it all out and did it the way that’s being advocated by most elm users.

Like what you read? Give art yerkes a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.