elm christmas

An Observable Update Function

←Previous postNext post →

The Elm Architecture is not as strict as you might think.

A 5 min read written by
Robin Heggelund Hansen

Most Elm applications follow The Elm Architecture (aka TEA). This means that most apps have, at least, three things: a model, a view and an update function. All tutorials and articles I've seen on TEA, defines the update function with the following signature:

update : Msg -> Model -> ( Model, Cmd Msg )

But just because this is the common definition, doesn't mean it always has to be.

A Problem

Let's pretend that you're the sole developer of www.trees.com, and you've just implemented a cart page in Elm. The page displays all the trees the customer is interested in buying, and at the bottom there's a nice, oaky purchase button. The next task on your todo list, is to have a login modal pop up if a customer presses this button without being logged in.

A login modal is self-contained. It only concerns itself with what the customer enters into that modal, and not about anything else in the app. It therefore makes sense to implement this modal as an independant file with its own model, view and update function. It might look a little like this:

module Trees.LoginModal exposing (Model, Msg, init, update, view)


import Html exposing (Html, div, text)
import Trees.Backend as Backend


type alias Model =
    { username : String
    , password : String
    }


init : Model
init =
    { username = ""
    , password = ""
    }


type Msg
    = SetUsername String
    | SetPassword String
    | Submit
    | SubmitResult (Result String ())
    | Close


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SetUsername username ->
            ( { model | username = username }
            , Cmd.none
            )

        SetPassword password ->
            ( { model | password = password }
            , Cmd.none
            )

        Submit ->
            ( model,
            , Backend.login model.username model.password
                |> Task.attempt SubmitResult
            )

        SubmitResult (Ok ()) ->
            -- TODO
            ( model
            , Cmd.none
            )

        SubmitResult (Err reason) ->
            -- TODO
            ( model
            , Cmd.none
            )

        Close ->
            -- TODO
            ( model
            , Cmd.none
            )

view : Model -> Html Msg
view model =
    div []
        [ text "TODO" ]

There's a bunch of stuff missing here, but it's a promising start. To hook this up to the rest of your application, you might write some code like this at the top update function of your application:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ...

        LoginModalUpdate loginMsg ->
            let
                ( newState, loginCmd ) =
                    LoginModal.update loginMsg model.loginModalState
            in
                ( { model | loginModalState = newState }
                , Cmd.map LoginModalUpdate loginCmd
                )

So now we have an, admittably unfinished, login modal with its own update-cycle. But how do we comunicate back to the main application that our task is complete? How can we tell the rest of the application that the user is now logged in, or that the modal doesn't need to be displayed anymore?

The update function doesn't really give us much flexibility here. We can return some new state, and optionally ask our parent to perform a side effect, but not much else. We could update a field in our model and have the parent check that field continiously, but that doesn't feel quite right.

A Solution

The fact that the update function returns a tuple with a new modal and a command is really just a suggestion. The update function can, of course, return whatever you like. So let's use a custom type to specify what LoginModal could potentially return:

module Trees.LoginModal exposing (Model, Msg, UpdateResult(..), init, update, view)


import Html exposing (Html, div, text)
import Trees.Backend as Backend


type alias Model =
    { username : String
    , password : String
    }


init : Model
init =
    { username = ""
    , password = ""
    }


type Msg
    = SetUsername String
    | SetPassword String
    | Submit
    | SubmitResult (Result String ())
    | Close


type UpdateResult
    = ModelChange Model
    | Command (Cmd Msg)
    | LoginComplete
    | LoginFailed String
    | LoginAborted


update : Msg -> Model -> UpdateResult
update msg model =
    case msg of
        SetUsername username ->
            ModelChange
                { model | username = username }

        SetPassword password ->
            ModelChange
                { model | password = password }

        Submit ->
            Backend.login model.username model.password
                |> Task.attempt SubmitResult
                |> Command

        SubmitResult (Ok ()) ->
            LoginComplete

        SubmitResult (Err reason) ->
            LoginFailed reason

        Close ->
            LoginAborted


view : Model -> Html Msg
view model =
    div []
        [ text "TODO" ]

So now the parent update function can easily check what the result of updating the modal is, and can act accordingly.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ...

        LoginModalUpdate loginMsg ->
            case LoginModal.update loginMsg model.loginModalState of
                ModelChange newState ->
                    ( { model | loginModalState = newState }
                    , Cmd.none
                    )

                Command subCommand ->
                    ( model
                    , Cmd.map LoginModalUpdate subCommand
                    )

                LoginComplete ->
                    ( model
                    , Backend.completeTreePurchase
                        |> Task.attempt OnTreePurchase
                    )

                LoginFailed ->
                    -- TODO: Error message should be displayed in view
                    ( { model | errorMsg = "Login failed. Please check your username/password and try again." }
                    , Cmd.none
                    )

                LoginAborted ->
                    -- TODO: Should probably ease this requirement
                    ({ model | errorMsg = "Purchase can only be completed if you log in." }
                    , Cmd.none
                    )

Let the sale of trees, commence!

←Previous postNext post →