Question about good taco technique for Elm SPAs

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

8 Likes

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 ]
            ) 
2 Likes

@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.

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.

1 Like

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

2 Likes

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.

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.

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 ?

1 Like

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.
2 Likes

I think I figured out a reason why you shouldnt return (PageModel, Cmd Msg, Taco) instead of (PageModel, Cmd Msg, ExternalMsg).

It would be impossible for pages to change page. Suppose you want one case in your page update function to change the page, say from Login to Home. (Im not 100% sure why you would want to do this, but its plausible, so bear with me). You would have to change Page in your main Model to Page.Home homeModel. But the return value of the Login.update function is Login.Model, not Page.Login Login.Model.

@Daniel_Cardona
I havent really heard of this translator approach, but I can see the disadvantage you mention; that you need to have this translator msg type and a translator function.

You said the ExternalMsg approach is prone to circular dependencies. I havent really had that kind of problem, but I can imagine a few different ways to do ExternalMsgs. Maybe my way has avoided a circular dependency problem.

The way I do ExternalMsg is that each child-module has its own ExternalMsg type (Login.ExternalMsg, Settings.ExternalMsg, etc). The ExternalMsg is handled in the parent-module (Update.elm). With this approach, the dependency structure isnt any different than if you didnt have any ExternalMsg at all. If you dont need to do ExternalMsg stuff, the parent just imports the child. If you do do ExternalMsg stuff, you handle it in the parent, and it lives in the child, you still only need the parent to import the child.

@eriktimmers
I guess you could do that, but then I think your type signature is not going to scale well. If your project changes or grows, and your child has to return a new type (other than User for example), then you need to change your type signature, and the return value under every case. Also, all of your cases just get bigger because they all need to return a new user plus a new whatever else.

Children could work (receive and return) with extensible records which are compatible with Taco. E.g. if you want to be sure apiUrl is not modified then just exclude it from returned extensible record. Example:

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

type alias WithoutApiUrl a =
    { a
        | user : Maybe User
        , seed : Random.Seed
    }

So one is still able to pass and receive the Taco with safety guarantees.

@Chadtech fair point - I assumed here that not all your pages actually do want to update the Taco, but I think the compiler will be your friend here. The cases will already need to return the Taco otherwise, right? So I think the difference in returning the User instead shouldnā€™t make any difference.

@akoppela I think that is quite in line with my suggestion. If I needed to return the User AND the Seed I would probably go for an alias anyway. Using an extensible record could be a great addition though.

By the way, I do not get the benefit of using ExternalMsgs over returning data. I donā€™t think changing the page is something youā€™d do via an ExternalMsg. To me it makes more sense to send back data than a Msg the parent should recognize. Any has an explanation?

Yeah I think you are right, aside from being literally different, I dont see a structural difference between (Model, Cmd Msg, User) and ( Model, Cmd Msg, Taco ).

Regarding the change the Page via ExternalMsg, yeah I also dont see that as happening. I would do Navigate.newUrl or whatever and let the route handling stuff take care of it. But its not totally impossible as a product constraint. Like, suppose you wanted to make the equivalent of a SPA but without a url(maybe its in a weird environment where theres no url like an electron app, or its just a widget in a bigger application). In that case you wouldnt have any navigation stuff, and I dont know another option besides an ExternalMsg

One can think of external messages as being requests for something beyond the scope of a particular unit to take action. For example, navigating away from a page is beyond the scope of the page. That navigation could be handled by sending a newUrl command or it could be handled by sending an external message. From the pageā€™s standpoint, it doesnā€™t matter. In fact, we could decide further up the hierarchy either to take action directly on the external message or to generate a newUrl command. External messages fit with the ā€œeffects as dataā€ approach and if used in preference to commands (up until a translation at the top level) would arguably have payoffs from a testing standpoint.

As for circularity, there is certainly a risk that an external message from a child could cause a reaction in the parent thereby causing a further update of the child thereby causing another external messageā€¦ Sadly, the best advice I can give here is to try not to further update the child when interpreting an external message from the child. The reason one doesnā€™t often see this problem with commands isnā€™t because commands are fundamentally different but because most commands donā€™t have semantics likely to lead to loops.

Mark

2 Likes

One can think of external messages as being requests for something beyond the scope of a particular unit to take action.

I like the way you put it there. Thanks @MarkHamburg!

One drawback I see from using the Taco as a return value is that the signature of the function loses some of its information.
When you return a User, I can understand from the signature that the update function wants to update the User in the model.
When you return a Taco, I can understand that it wants to update the Taco. But it can be anything in that Taco.
That makes understanding the application for me more difficult.

For me, the need to update the signature if the function needs to be able to do more, is actually helpful, because it will remind me to think about whether that is something I really want to do.

1 Like

@akoppelaā€™s suggestion to use extensible records gives you both the ability to take and return a Taco, and to have a descriptive input/output signature.

Iā€™ve setup a simple template if you want to check it out: https://github.com/DanielCardonaRojas/elm-spa-template

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.