Thomas Bandt

About Scaling Model-View-Update

You've learned about MVU and started building a real app that is now growing beyond its first view. Now you are wondering: How can that be structured, how does it scale? Does it scale at all?

Published on Wednesday, 01 July 2020

I bet that everyone who does get started with "The Elm Architecture" sooner or later arrives at that point. No matter if they use Elm, Elmish, Fabulous, or other libraries and frameworks. So you are not alone. And it caused me to scratch my head, too.

The Problem

When looking at the ubiquitous counter examples, the advantages of MVU quickly become apparent. Besides other things, the uni-directional dataflow makes it very easy to reason about a program. At least for such simplistic scenarios.

In real-world scenarios, however, the number of messages and the size of your update function will quickly grow. You will want to split your first program into multiple parts, be it multiple "pages" or "widgets."

To maintain the single message loop you will need to break up your program into parents and their children. The entire process is being described in the wonderful Elmish Book. If you want to learn more first, I recommend taking a break and read its chapter about splitting programs first.

What you will end up with is a set of relatively slim children. Which do not know anything about their parents nor their siblings. That's the plus. The downside: Parents need to know a lot about their kids. In fact, for specific scenarios, you will need to intercept messages sent by children in one of their parents. Or you will introduce an additional message type that is being used for "external communication" (the intent pattern).

As a result, a parent's update function will be not only dealing with its own logic, but it will also handle a lot of things for its children. Which will probably feel counter-intuitive, to say the least.

So what can be done? Well, it depends on what you want to sacrifice. There's no such thing as free lunch, as always.

Option 1: Embrace The Original Idea

Yes, MVU will make you write a lot of boilerplate code. Yes, every component of your application will look the same. But that can be considered a good thing. Because it narrows down the "implementation path" massively. There's not much room left to do something wrong.

Which might pay off in the long run. You will probably be able to easily reason about the behavior of the application after a long time. You won't end up with different implementation styles that depend on the person who worked on it last and their state of knowledge. This also means that it will probably make it easier for new team members to understand what is happening and to get started.

Bonus: Even if they break the whole application by changing something, the compiler will tell them very early. And the moment they mastered their first component, they have learned most of what is needed to continue to work on different parts of the application.

Another positive side-effect: You free yourself from all the trends and hypes of our industry, at least for the scope of the particular project you're choosing to work with MVU. For Elmish, for example, the view might still end up being rendered with React – but that's just an infrastructure layer below your application. React moves from classes to functions, or it introduces new features that break existing patterns? You don't need to care, you can stay in your comfort zone on top of all that.

Option 2: Split It Up

If you still have a hard time committing yourself to write code you do not want to write, no matter what, you can break the whole thing into multiple programs. Although it may seem counter-intuitive at first, it is what some MVU implementations have to do anyway.

So how do you do that? Well, it depends on what you are building. For mobile applications with Fabulous, you might want to keep building your app with ordinary pages. The same goes for web applications: There is nothing wrong with having multiple (server-side rendered) pages that host an independent Elm(ish) program.

For web applications built with Elmish, there's even one more option. The Feliz project from Zaid Ajaj offers a React hook called Feliz.UseElmish. This allows you to build a (more or less) normal React application written in F# and composed of different React function components, which internally can use MVU if needed. This way, you can build simple components (think of navigation elements) without an artificial message loop. And still leverage all the advantages of MVU in case it makes sense.

A simple React function component without MVU (source):

module Dashboard

open Feliz

let render = React.functionComponent(fun () ->
    let context = React.useContext AppContext.instance
    Html.textf "Welcome, %s! " context.Session.User
)

A more sophisticated component with some internal logic that is worth to be implemented following MVU (source):

module Login

type private State =
    { UserName: string
      Password: string
      Error: string option }

type private Msg =
    | UserNameSet of string
    | PasswordSet of string
    | StartLogin of AppContext.ContextData
    | LoginFailed of string
    | LoginCompleted

let private init() = { UserName = ""; Password = ""; Error = None }, Cmd.none

let private updateSession (context: AppContext.ContextData) user =
    context.SetSession({ context.Session with User = user })
    LoginCompleted

let private validateCredentials context state =
    if state.UserName = "user" && state.Password = "test" then
        state, Cmd.ofMsg (updateSession context state.UserName)
    else
        state, Cmd.ofMsg (LoginFailed "Oops, user name or password are incorrect.")

let private update msg state =
    match msg with
    | UserNameSet userName -> { state with UserName = userName }, Cmd.none
    | PasswordSet password -> { state with Password = password }, Cmd.none
    | StartLogin context -> validateCredentials context state
    | LoginFailed error -> { state with Error = Some(error) }, Cmd.none
    | LoginCompleted -> state, AuthenticatedUrl.Dashboard.Navigate()

let render = React.functionComponent(fun () ->
    let state, dispatch = React.useElmish(init, update, [| |])
    let context = React.useContext AppContext.instance

    Html.div [
        Html.div [
            prop.text state.Error.Value
            prop.hidden state.Error.IsNone
        ]
        Html.input [
            prop.type'.text
            prop.placeholder "User name"
            prop.onChange (UserNameSet >> dispatch)
        ]
        Html.input [
            prop.type'.password
            prop.placeholder "Password"
            prop.onChange (PasswordSet >> dispatch)
        ]
        Html.button [
            prop.onClick (fun _ -> dispatch (StartLogin context))
            prop.text "Get in"
        ]
    ]
)

What you will obviously lose that way is the advantages of a single application-wide message loop. You will need to find alternative measures to communicate between parents and their children, or different components at different places at the tree. But when you came this far, you for sure will find a suitable solution for that, too (hint: in the sample above, React Context was used).

Conclusion

Model-View-Update is a powerful concept, but it's far from perfect when applied in real-world applications. Whether or not this becomes a problem for you depends on what you are willing to make compromises for. No surprise, there is no free lunch.

What do you think? Drop me a line and let me know!