I think there are three main options here.
Option #1: Custom Cmd Types
Instead of returning a Cmd Page.Msg
from your Page.update
function, return a custom Action Page.Msg
type. This type can be deconstructed in the updateWith
function.
type Action msg
= EmbeddedCmd (Cmd msg)
| Login User
...
updateWith toModel toMsg model (pageModel, pageAction) =
case pageAction of
EmbeddedCmd cmd ->
({model | page = toModel pageModel}, Cmd.map toMsg cmd)
Login user ->
({model | page = toModel pageModel, user = user}, Cmd.none)
You can define all the standard Cmd functions in Action.elm:
-- In Action.elm
batch : List (Action msg) -> Action msg
map : (a -> b) -> Action a -> Action b
-- This one means you don't have to pass Browser.Navigation.Key to sub-pages.
-- It's unwrapped in Main and the navigation key is retrieved
-- from the main model in updateWith.
navigate : String -> Action msg
-- This one triggers a msg after some number of milliseconds.
delay : Float -> msg -> Action msg
delay milliseconds msg =
EmbedCmd (Task.perform (\() -> msg) (Process.sleep milliseconds))
-- This one means you don't need to directly access ports in sub-pages.
-- Instead the port is used in Main when the Action is deconstructed.
sendEvent : Value -> Action msg
sendEvent =
SendEventToJs -- This is a constructor of the Action type
-- It's also possible to get request-response like behavior with ports
-- When `SendRequestToJs` is deconstructed, updateWith will put the Decoder in a Dict in the main
-- model and send the Value out a port along with the dict key. The JS will do
-- something and send the response with that key, which Main will match up with the
-- decoder stored the Dictionary to get the Msg to update with.
-- Note that Decoders have functions inside of them, so storing them in your Model
-- in this way means that your Model can't be serialized using the Elm debugger.
-- I've personally found the trade-off to be worth it.
sendRequest : Value -> Decoder msg -> Action msg
sendRequest =
SendRequestToJs
A (common?) variant of Option #1 is to return a triple instead of a tuple.
Page.update : Msg -> Model -> (Model, Cmd Msg, Action)
This avoids having to wrap Cmds in Action.EmbeddedCmd and is easier to introduce into an existing application. Personally, I’ve found transitioning entirely over to an Action type and never using Cmd in pages to be the better option. Just re-define any Cmd producing functions you need so that they return an Action instead of a Cmd.
Option #2: Mapping Msgs
You could also write the Page.update
function like this:
type Msgs model msg =
{ user : User
, toMsg : Msg -> msg
, setUser : User -> model -> model
, doSomething : String -> Int -> msg
}
Page.update : Msgs model msg -> model -> Model -> Msg -> (model, Cmd msg)
In this way the Page.update function deals with the main model & msgs directly, but through acessor, setter, and constructor functions. You can give it access to root-level msgs that it might need to create by adding more functions to the Msgs
record. Same with manipulating the root-level Model
by adding more functions of the form model -> model
.
This seems to produce a lot of boilerplate in my experience. Overall Options 1 & 3 seem to work better.
Option #3: Flatten your Model
Another solution is to not have sub-page models or sub-page msgs. This doesn’t always make sense, but I have one relatively large application that just passes the root Model down to every sub-page, and they generate root-level Msgs rather than page-specific ones. The page
field on the Model is just a discriminator without any additional data:
type Page
= ContactsPage
| EventsPage
| StudentsPage
| SettingsPage
This works quite well for that application. I think the reason is the entire application is manipulating a single “project file”. Lots of apps are CRUD like with pages such as “Index Students”, “Show Student”, “Index Courses”, “Show Course”, “Create Course”, etc. Each of these pages is more-or-less a completely independent thing.
The application I use this architecture in isn’t really like that. Instead, there’s a single “Project File” that behaves more like a Word Document or an Excel File. Users open the project, update data, and save the project. Each “page” in the Elm app is just viewing and manipulating a different sub-set of the project file.
A side-effect of this that might not be immediately obvious is that pages no longer have their own update functions. Instead, the root update function handles everything. (It can still delegate to other functions, but you get to decide how to break it down based on the needs of the code rather than the needs of ux-dictated page breakdowns.)
Another benefit is not losing page state like partially filled forms when switching between pages. This is usually a win from a UX standpoint.
The application I just described is embedded in a bigger app for manipulating user accounts, permissions, project file management etc. That wrapping application is much more CRUD like and uses Option #1 to great success. Given a large enough application, the options aren’t mutually exclusive.