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 Page
s. 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