Elm architecture aka MVU was a breath of fresh air when I encountered it. It remains the best UI dev experience I know of. As I've done larger and larger projects with it, used it on the backend, and seen its usage in other contexts, it has some friction points. Here are my current thoughts on how to resolve those sore spots.
Code below is in F# unless otherwise noted.
Centralizing logic into update
// sus // | // v let init initArg : (Model * Cmd) = ...
One thing that has always bothered me about init
is that it comes out swinging by returning a Cmd
. This can be a problem in some UI technology (e.g. WPF) that is sensitive to the timing of initializing elements vs side effects. I recall at least one issue about this in the Elmish repo.
It's also annoying that all decisions are made in update
... except for the 1 made in init
, as indicated by returning Cmd. This means that to test an MVU program both functions have to be exercised. I played around with my own backend-focused lib and determined that this doesn't have to be the case.
I had to write my own library for backend workflows because they have different run characteristics from UIs, but my lib used identical MVU functions, minus
view
.
All logical decisions could be done in update
without changing it in any way. Just move init
's Cmd-producing decision into its own Msg case. Then get rid of init
. That works, but the caller has to know how to construct an initial Model
and Msg
to send to update
. So then I thought, why not use init
for this purpose instead... basically an adapter function to translate the data you have (initArg
) into the form needed to kick off the update
function.
let init initArg : (Model * Msg) = ... // \---------/ // tells how to | // start update | // ----------- // | // v // /-------\ let update msg model : (Model * Cmd) = ...
For UI platforms that need it, this would also provide an opportunity for UI elements to be initialized from the Model, and the Msg queued for later processing, without kicking off any side effects.
At the time I made this change, I already implemented a similar tactic for resumable workflows. My code had the model from the previous completed workflow. It just needed to know the correct Msg to resume the workflow. I created a function called
resume
for that purpose. So it seemed natural forinit
to do the nearly same thing to start the workflow.
Parameter order
Another thing that always bothered me about Elm is the parameter order of update
. You can look at Cmd as a future Msg, so the update
function returns nearly the same types as its inputs, but with their order swapped.
// should be swapped // ------ // | | // v v let update msg model = model, Cmd.none
This is definitely a minor point, practically speaking. I just have to bake in an argument swap when I'm testing
update
. But it's logically incorrect for howupdate
is used. And I can't unsee that.
It is a winding road to explain how Elm arrived at that. But it has to do with its Haskell heritage; Haskell's focus on laziness; and laziness being represented by the right fold operation. Which in this case puts model
as the 2nd argument. Whereas left fold -- which would have model
as the 1st argument -- is considered eagerly computed. It's an inherited philosophical choice, not a practical one, since Elm doesn't focus on laziness like Haskell. You can even observe this cognitive dissonance in Elm's List.foldl
which processes the list left to right, but combines elements right to left as a right fold would, so it behaves unexpectedly when order matters.
// F#, Haskell, and every other language's left fold result "abc" = List.fold (fun s1 s2 -> s1 + s2) "" ["a"; "b"; "c"]
-- Elm's left fold result "cba" == List.foldl (\s1 s2 -> s1 ++ s2) "" ["a", "b", "c"] -- ^ -- \ -- huh?
It seems like update
arguments are meant to match Elm's incorrect left fold behavior. This is easily fixable by swapping input argument order.
// before let update msg model : (Model * Cmd) = ... // \ / // \/ swap // /\ // after / \ let update model msg : (Model * Cmd) = ...
Later in this article, you'll see an example of testing
update
logic by providing a list of Msgs, then checking that the correct side effects were produced. Testing made the most sense with left fold. Which made the most sense with the corrected version of update.
Untestable Cmd
Elm's representation of side effects is with Cmd. The Elm philosophy is that users are not allowed to do side effects in Elm, so Cmd is locked down. You can use a Cmd-generating platform function provided by Elm. Or when you inevitably need a side effect that Elm doesn't cover, you must send data out of an asynchronous port to let JS run your side effect and send the result back in thru another async port.
In Elmish, the F# library, Cmd is an alias for a list of user-declared functions. Each of which is provided with a dispatcher to send Msgs back. (Unlike Elm, you actually get to write your side effects in F#! And JS <-> F# interop is easy.) So your update
code returns functions to run when it's time to call side effects.
In both cases, Cmd is untestable. In Elm it's intentionally opaque, even the fact that it's a collection under the covers. There is no way to test it at all. In Elmish you could potentially test generated Cmds if your update
fn also accepts side effect dependencies as parameters and you pass in mocks instead of the real ones. But I consider this to be the hard way to test things, on top of polluting update
with side effect related concerns.
I'll be honest, I never did a lot of unit testing on frontend code. It was just not needed. But backend is a different story. It is all about making business decisions and executing the chosen side effects. It needs to be tested thoroughly for correctness.
Declarative side effects
For backend, the Model is less important to test. Rather, I needed to be sure that the correct side effects were decided. Cmd being hard to test was just not acceptable. So I introduced another step in the MVU process. This involves adding another type similar in style to Msg
but for the purpose of declaring a side effect to run. I called it Effect
. Then I introduced a function to perform
those side effects.
type Msg = ... // define possible side effects, a DU like Msg type Effect = ... type Model = { ... } let init initArg : (Model * Msg) = ... // test with equality // v let update model msg : (Model * List<Effect>) = ... // could be Task and/or synchronous too // v let perform effect : Async<Msg> = ...
Like Msg, Effect is a user-declared type.
Above, update
is living its best functional life. Its inputs and outputs are value-equatable immutable data. That means side effects can be tested for correctness with simple equality checks. Like so:
// expected model effects // | | // v v let expected = { ... }, [LoadWidget 123] // basically left fold model simulated scenario // | | | // v v /----------------\ let actual = sim MyWorkflow.update { ... } [Go 123; LoadFailed] // test workflow with simple equality assertEqual expected actual
Notice that I don't need to involve init
in this test, since its side effect decision is now where it belongs... in update
.
Above, LoadFailed is an actual Msg that would return from perform
when there was a database failure. The only Effect produced is the attempt to load a widget. After that, we're expecting that upon receiving LoadFailed the update
fn will stop and not try to produce more side effects. If it does produce more Effects, assertEqual
will fail the test. So this not only tests that it does what it should but also that it doesn't do more than it should.
This is what the relevant section of perform
might look like.
let perform (dbConfig, logger: ILogger) effect = match effect with | LoadWidget widgetId -> async { try let! widget = Db.findWidget dbConfig widgetId return WidgetLoaded widget with ex -> logger.LogError(ex, "Widget load failed {widgetId}", widgetId) return LoadFailed }
We take advantage of partial application to inject dependencies as the first argument.
With this arrangement, side effects no longer need to be mocked -- with all the overhead involved in that -- to test logical decisions. In fact, side effects play no part at all in unit testing. They only have to be integration tested.
We used this same adaptation on frontend. There, having a place for side effects prevented them from being introduced accidentally in update
. We also found it made update
code easy to reason about. When we did have a UI bug, it was usually quick to spot and fix. At least it was once we got away from sharing state across pages.
Nested page fatigue
Everything I've mentioned so far is easily usable inside Elmish, maybe even Elm, with tiny adapter functions. That's what I did for our Elmish frontend. This one could need more than that, not sure yet.
What is nested page fatigue? It's the vaunted fractal nature of The Elm Architecture. It is great to a degree, but it requires wiring together too dang many things between a parent and child. Every time you create a child page, you have to wire it into the parent page in every possible way.
- Msg case for child msgs
- Model property or case for child model
- init or update case to initialize child
- depends on whether it uses navigation
- update case to call child update
- view code to call child view
After hundred thousands of lines of UI code and many dozens of pages, the friction is real. And this is the most common complaint about MVU. The backend library taught me that it was possible to avoid this fatigue while still using MVU all the way down.
You see, when I tried to compose F# backend MVU workflows in the traditional way, similar to frontend, it made things very awkward and mixed a lot of concerns between workflows.
If this were OO, that way of wiring things would be a dark-but-humorously-named anti-pattern called Inappropriate Intimacy.
Then I noticed when non-MVU code called MVU workflows, it first used an adapter function to convert it to a normal Async-returning function. Then I realized that this is the best way to compose MVU workflows... treat each one as a single overall side effect. This makes composing them easy:
async { // workflow1/2 are records containing init, update, etc let! result1 = runWorkflow workflow1 initArg let! result2 = runWorkflow workflow2 result1 ... } // or thru perform let perform deps effect = async { match effect with | MyEffect arg -> let! value = runWorkflow workflow1 arg return EffectHappens value }
Right okay, but how does that help frontend?
The purpose of these backend workflows is to make correct decisions about side effects and run them. So composing them as side effects made sense. The purpose of a frontend program is decide about and display UI.
Composition through view
This gave me the idea that a whole Elmish-React child program could be packaged up as a React component, then displayed along side the other HTML elements (also React components) in the parent's view
.
let view model dispatch = div [] [ match model.Route with | CourseList -> let initArg = () Elmish.page initArg CourseList.init CourseList.update CourseList.view | CourseDetail courseId -> Elmish.page courseId CourseDetail.init CourseDetail.update CourseDetail.view ]
Since Elmish has termination support, the self-contained page would be coded to run clean up when it is switched out from view. There's no longer a need to wire it thru all the parent's functions and types as before.
What if I want communication back to the parent? For example, the parent needs to know when a cancel button is clicked on the child page to no longer show it. Easy. Make it an extra argument to the child's view function. Partially apply it.
let onClose () = dispatch Closed
Elmish.page ... (CourseDetail.view onClose)
When I looked around, I realized this was not a particularly original idea. This is the common way Fable.React.useElmish
is employed, but being hosted directly from React JS components instead of parent MVU views. (Which also avoids all that parent-child wiring.) I also found common React libraries that tied the navigation route directly to the displayed React components, which only makes sense.
Composition through
view
would also naturally fit building as dynamically loadable ESM modules instead of a fully bundled app.
This one, I haven't tried yet. The Elmish.page
function doesn't exist. I need to write that and figure out how to adapt it to Elmish.mkProgram
. It looks to be technically possible, and I am excited to try it the next time I build another MVU app.
Summary
MVU is an amazing pattern that helps me write highly maintainable and correct UIs. I love it. That's probably why the core pattern remains essentially the same even after my fixes.
Yet, these changes have materially improved the experience for me. Maybe they could for you too.
Top comments (0)