Question about good taco technique for Elm SPAs


#1

Hey everyone,

Ive got a really particular and super pedantic question about good SPA practices that I dont know how to bring it up without some context. So I am going to write kind of like a tutorial on some Elm technique stuff that I have learned, and then with that context I will ask my question. Maybe I will get some good answers to my question, but also hopefully I can condense some knowledge for others. Let me start below:

Part 0: The Taco Architecture

If you are doing an SPA, you are probably going to represent your pages as a type like this…


type alias Model =
    { page : Page }


type Page
    = Home Home.Model
    | Settings Settings.Model
    -- ..

…where Model is your main application model, and then you have page-level models nested inside your main model. The page models contain the state information of their specific pages. Since Page is a union type, the states of pages are not concurrent: you either have the state of one kind of page, or the state of another kind of page, but not two page-states at once. This coheres pretty strongly with the actual UX of only being on one page at a time.

But there is also non-page-specific information in your program. For example, in…


type alias Model =
    { page : Page
    , user : Maybe User
    , seed : Random.Seed
    , apiUrl : String
    }

user, seed, and apiUrl is information that isnt specific to any particaulr Page.

Even tho that information is not page specific, its still used in page modules. For example, you want your home page to render a little differently if the user is logged in. So in your home module, you could write your view function like this…


view : Maybe User -> Home.Model -> Html Home.Msg
view maybeUser model =
    -- ..

…where the view function takes the Maybe User and makes a determination based on that value. Similarly, your settings page module is going to need your api url to make an http request in order to save the users settings; so you will need to pass that into the update function like this…


update : String -> Settings.Msg -> Settings.Model -> ( Settings.Model, Cmd Settings.Msg )
update apiUrl msg model =
    --..

There is a problem that emerges from all this, which is that page update and view functions end up using a lot of non-page-specific information. Each additional piece of information is another parameter in your type signature, and even just two or three new parameters really makes the whole thing visually messy and extending off the screen. More importantly tho, you end up spending your development time grooming type signatures, because you are inevitably going to be adding or removing parameters to these big important functions, which means tweaking slightly the function in both the module its used and in the module where its defined. (This gets a million times worse when you have layer after layer of type signatures that need tweaking.)

Saving our attention and time, and keeping things organized and visually clean, Ohanhi and his colleages invented this ‘elm-taco’ architecture, where everything thats not page specific is just put into this thing called a Taco.


type alias Model =
    { page : Page
    , taco : Taco
    }


type alias Taco =
    { user : Maybe User
    , seed : Random.Seed
    , apiUrl : String
    }

When you have a taco, you can just pass it into every page specific update and view function, and never worry about it again. Everything gets every bit of globally relevant information, and the type signature is tiny and never changes.

-- Home.elm

update : Taco -> Msg -> Model -> ( Model, Cmd Msg )
update taco msg model =
    -- ..

view : Taco -> Model -> Html Msg
view taco model =
    -- ..

Thats the how and why of this Elm Taco stuff (as I see it).

Part 1: Parent-Child module communication

Just picking off from where we were above, imagine we have a big SPA, and it has a Taco and it has Pages. Theres a different challenge, which is that sometimes your page modules need to change values in the Taco. The login page for example, needs to change the user field from Nothing to Just user. But it cant do this if the return value of your login update function is ( Login.Model, Cmd Login.Msg ). Its the same story if you want to consume the randomness seed in a page module. You could use a random seed, but then for real randomness you need to pass on a new seed out of the child module and into the parent module, and theres just no room for a Random.Seed in (Model, Cmd Msg).

What rtfeldman does in his elm-spa example is he has his child update functions return (( Model, Cmd Msg), ExternalMsg). It our case it would be something like…


type ExternalMsg
    = SetUser User
    | SetSeed Seed
    | DoNothing

This basically lets the child module give some instructions to the parent module, like this…

    -- Update.elm

    LoginMsg subMsg ->
        let
            ((newLoginModel, cmd), externalMsg) =
                Login.update subMsg loginModel
        in
        case externalMsg of
            SetUser user ->
                { model 
                    | page = Page.Login newLoginModel
                    , taco = Taco.setUser user model.taco
                }

            -- ..

This has worked pretty well for me, and I have tried really hard to formalize these practices in my own package Chadtech/return.

The Question

I recently became aware of an alternative technique to the ExternalMsg stuff, thats seemingly much less popular practice but also looks to me to be at least as good.

What if instead of returning an ExternalMsg, child update functions just returns a new Taco?

So instead of returning an ExtneralMsg, which necessarily is only being used to tell the parent how to change the Taco, it just changes the Taco itself. Heres what I mean…


-- Login.elm

update : Taco -> Msg -> Model -> ((Model, Cmd Msg), Taco)
update taco msg model =
    case msg of
        UserLoggedIn (Ok user) ->
            ( (model, Cmd.none)
            , Taco.setUser user taco
            )


-- Update.elm

    LoginMsg subMsg ->
        let
            ((newLoginModel, cmd), newTaco) =
                Login.update subMsg loginModel
        in
        { model 
            | page = Page.Login newLoginModel
            , taco = newTaco
        }

…and compare that with the ExternalMsg approach…


-- Login.elm

Type ExternalMsg 
    = SetUser User

update : Taco -> Msg -> Model -> ((Model, Cmd Msg), ExternalMsg)
update taco msg model =
    case msg of
        UserLoggedIn (Ok user) ->
            ( (model, Cmd.none)
            , SetUser user
            )

        -- ..

-- Update.elm

    LoginMsg subMsg ->
        let
            ((newLoginModel, cmd), externalMsg) =
                Login.update subMsg loginModel
        in
        case externalMsg of
            SetUser user ->
                { model 
                    | page = Page.Login newLoginModel
                    , taco = Taco.setUser user model.taco
                }

            -- ..

and you could easily streamline the whole thing with a helper function…


    LoginMsg subMsg ->
        Login.update subMsg loginModel
            |> recombine Page.Login LoginMsg


recombine : (subModel -> Page) -> (a -> Msg) -> ((subModel, Cmd a), Taco) -> ( Model, Cmd Msg )
recombine pageCtor msgCtor ((subModel, subCmd), taco ) =
    ( { page = pageCtor subModel, taco = taco }
    , Cmd.map msgCtor subCmd
    }

Can anyone think of any draw backs of this approach of returing the page model and the Taco, instead of an ExternalMsg? Is there something I am missing, or is it really an improvement on the ExternalMsg stuff? Or, am I wrong about some other aspect of this architecture?

Best,
-Chad


#2

It depends on the nature of the ExternalMsg. If it doesn’t need side-effects then it is a reasonable approach to just return the updated Taco. If the Taco update needs side-effects then the second approach would not fit (in my perspective).

I have a similar architecture in the app that I’m working on right now and some of the upstream messages require side-effects so I decided to have something similar to your first approach only that I have only one set of upstream messages that are handled by the taco update. Translated to your vocabulary would look something like this:

    LoginMsg subMsg ->
        let
            ((newLoginModel, cmd, tacoMsg) =
                Login.update subMsg loginModel
        in
        updateTaco tacoMsg { model | page = Page.Login newLoginModel} (Cmd.map LoginMsg cmd)

updateTaco: TacoMsg -> Model -> Cmd Msg -> (Model, Cmd Msg) 
updateTaco msg ({taco} as model) cmd = 
    case msg of 
        SetUser user -> 
            ( {model | taco = {taco | user = user}}
            , Cmd.batch [ cmd, Ports.setToken user.token ]
            ) 

#3

@pdamoc What kind of side effect would you prefer to execute from the top-level update?

I cant think of a reason why I would prefer to do it in the top level if the bottom level is capable of modifying every part of the global state.


#4

This is a very good question!

I’ve looked a little bit at the side-effects I have in my updateTaco equivalent and I think that they can be used inside an inner module (they are mostly port calls). I need to research some more as this is a good opportunity to simplify the code.


#5

The problem with updating the taco directly is that it shifts the taco from being a collection of data structured to be easy to use — i.e., something where a big flat record works great — into being something that has to be designed with data validity concerns in mind and that can make the taco a bit more opaque to consume.

More concretely in your example, should arbitrary parts of your application be able to change the API URL? If not, then arbitrary parts of your application should not be able to change the taco.

If I did want to have some form of threaded global state — e.g., for random seeds — then I would probably structure my update functions as:

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

Working with a child becomes a matter of projecting down on the first element in the tuple, performing the sub-update, and then embedding the first element of the tuple back:

update : Msg -> ( Model, ThreadedState ) -> ( ( Model, ThreadedState ), Cmd Msg )
updatge msg ((model, state) as modelState) =
    case msg of
        ToChild childMsg ->
            modelState
                |> Tuple.mapFirst .child
                |> childUpdate childMsg
                |> Tuple.mapSecond (Cmd.map ToChild)
                |> Tuple,.mapFirst (Tuple.mapFirst (asChildIn model))

Mark


#6

We both understand that in theory, the apiUrl should never change, and you are saying we need our practice to conform to that theory, so that in practice we cant change the apiUrl either. However, I dont really see the developer wanting to change the apiUrl as plausible (either by accident or because its part of some hack to get things to work). Furthermore I usually wrap these never-supposed-to-change values like this…

type ApiUrl
	= ApiUrl String

With a type like that, if theres no function in the whole project that creates an ApiUrl, you just cant change that fields value, even if you had access to your record.

So in purely practical terms, I dont see the value of limiting child update functions to only to the values they need. Furthermore I see a penalty in the form of creating and maintaining a step that deliberately filters out values of the global state. But if theres something I am missing please tell me. This is a really interesting subject and I have heard different opinions. I would love to get to the bottom of all this.


#7

The API URL was the most extreme example from your initial post. My point was that decisions about what should be mutable(*) from arbitrary points in the code is likely to not always have agreement. That is easily the case with multiple developers. It can be the case even with a single developer. So, it can be useful to structure APIs in a way that says “this can only be modified from here and that helps protect the following invariants”.

Wrapping the value in a constructor that you can go hunting for can help but then makes all of the consumers work harder to unwrap the value from the taco. This is a balance issue between making things easy to read and making things easy to mutate. Different cases will likely call for different choices.

Mark

(*) Yes, I said “mutable”. And yes, Elm’s data structures are all immutable. But the whole point of the update function is to mutate the model — albeit by returning a new model. The question at hand is whether arbitrary parts of the program should be able to mutate the taco portion of the model as part of building the new model returned by update.


#8

I have been working on a dashboard style spa for a while a stumbled on this same issue. I’ll share my approach which I’ve been happy with, it might help.

I have my application Model pretty much structured the same way @Chadtech mentions above, it has a page property which is a union type.

Taco for global context, translator pattern for child to parent communication and Kris Jenkins approach to structure application scenes.

I think a problem with ExternalMsg is that it is prone to circular dependencies and creates a stronger coupling between the child and parent modules.

I like the translator/smart tag approach better because submodules don’t need to to know about parent module types, hence are better decoupled and can preserve expected TEA function signatures. The child module can still define its own Msg type and Model type.

On the parent side translators are defined as a mapping from child messages to its own, the translator is just a function like this:

-- Types.elm parent module

import Child.Types as Child
import Data.Taco as Taco

childTranslator : Child.Msg -> Msg
childTranslator msg =
    case msg of
        Child.SomeMessage ->
            SomeParentMsg

        Child.UpdateTacoUserLocation loc ->
            TacoUpdate <| Taco.UpdateLocation loc

        msg_ ->
            ChildMsg msg_ -- Parent tags child messages anyways

Then in the parent module all I do is Html.map and Cmd.map translators over view functions and update functions to lift messages from child msg to parent msg. Messages intended for the parent are ignored in the child module by returning
(model, Cmd.none) in the update function.

I have a separate Taco module with its own update messages and a top level msg TacoUpdate message to do any global context updates.

type Msg
    = ChildMsg Child.Msg
    | TacoUpdate Taco.Update
    ...
    | NoOp

As a bonus using elm-return you get really nice page updates:

updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg )
updatePage page msg model =
    case ( msg, page ) of
        ( HomeMsg pageMsg, Home pageModel ) ->
            Home.update pageMsg pageModel
                |> Return.mapBoth Msgs.homeTranslator Home
                |> Return.map (\p -> { model | page = p })

        ( LoginMsg pageMsg, Login pageModel ) ->
            Login.update model.flags pageMsg pageModel
                |> Return.mapBoth Msgs.loginTranslator Login
                |> Return.map (\p -> { model | page = p })

An obvious downside to this is that extra message must be created in the child module just to forward them to the parent in the translator but other then this I don’t see major inconvenients.

I might be missing some advantages of ExternalMsg over translators, what do you guys think ?


#9

Can’t you just directly return the data you (might) want to update.
E.g. for the child: update : Taco -> Msg -> Model -> ( Model, Cmd Msg, User ).

Then

  • you do not have any unallowed Taco updates;
  • your parent does not need know the logic of its child’s external messages;
  • the compiler helps you connecting the dots;
  • the update signature tells you exactly what the child’s capabilities are.