Nested TEA
If you have a Browser.application
that is getting large you may want pull some of the logic, such as the logic for a particular page, into its own module.
This is one common way of doing that.
”Nested TEA” is a way to “nest” the Elm architecture so a module can have its own model/view/update cycle, with messages delegated to and from the top-level module.
Let’s say you have an application like this:
module Main exposing (main)
import Browser exposing (Document, UrlRequest(..))import Browser.Navigation as Navimport Html exposing (Html)import Html.Attributes as Attributeimport Html.Events as Eventimport Url exposing (Url)
main = Browser.application { init = init , update = update , view = view , subscriptions = subscriptions , onUrlRequest = UrlRequested , onUrlChange = UrlChanged }
type alias Model = { page : Page , navKey : Nav.Key , clicked : Bool }
type Page = Home | Clicker | NotFound
pageFromUrl : Url -> PagepageFromUrl url = when url.path is "/" -> Home "/clicker" -> Clicker _ -> NotFound
init : {} -> Url -> Nav.Key -> { model : Model, command : Cmd Msg }init _ url key = { model = { page = pageFromUrl url , navKey = key , clicked = False } , command = Cmd.none }
type Msg = UrlRequested UrlRequest | UrlChanged Url | ButtonClicked
update : Msg -> Model -> { model : Model, command : Cmd Msg }update msg model = when msg is UrlRequested urlRequest -> { model = model , command = when urlRequest is Internal url -> Nav.pushUrl model.navKey (Url.toString url)
External url -> Nav.load url }
UrlChanged url -> { model = { model | page = pageFromUrl url } , command = Cmd.none }
ButtonClicked -> { model = { model | clicked = True } , command = Cmd.none }
view : Model -> Document Msgview model = when model.page is Home -> { title = "Home" , body = [ Html.a [ Attribute.href "/clicker" ] [ Html.text "Go click the button" ] ] }
Clicker -> { title = "Clicker" , body = [ Html.button [ Event.onClick ButtonClicked ] [ Html.text (if model.clicked then "You clicked me!" else "Click me!" ) ] ] }
NotFound -> { title = "Not Found" , body = [ Html.text "Can't find that url" ] }
subscriptions : Model -> Sub Msgsubscriptions _ = Sub.none
Compile and run the application and you should see a link to a page with your button. Clicking the button will change the text.
If we want to pull our Clicker
page into its own module, it could look like this (notice we created a src/Pages
directory for it to live in):
module Pages.Clicker exposing (Model, Msg, init, update, view)
import Browser exposing (Document)import Html exposing (Html)import Html.Attributes as Attributeimport Html.Events as Event
type alias Model = { clicked : Bool }
init : Modelinit = { clicked = False }
type Msg = ButtonClicked
update : Msg -> Model -> { model : Model, command : Cmd Msg }update msg model = when msg is ButtonClicked -> { model = { model | clicked = True } , command = Cmd.none }
view : Model -> Document Msgview model = { title = "Clicker" , body = [ Html.button [ Event.onClick ButtonClicked ] [ Html.text (if model.clicked then "You clicked me!" else "Click me!" ) ] ] }
Then we’d update our Main
module to delegate to that page’s init
, update
, and view
:
module Main exposing (main)
import Browser exposing (Document, UrlRequest(..))import Browser.Navigation as Navimport Html exposing (Html)import Html.Attributes as Attributeimport Html.Events as Eventimport Url exposing (Url)import Pages.Clicker
main = Browser.application { init = init , update = update , view = view , subscriptions = subscriptions , onUrlRequest = UrlRequested , onUrlChange = UrlChanged }
type alias Model = { page : Page , navKey : Nav.Key , clicked : Bool }
type Page = Home | Clicker | Clicker Pages.Clicker.Model | NotFound
pageFromUrl : Url -> PagepageFromUrl url = when url.path is "/" -> Home "/clicker" -> Clicker "/clicker" -> Clicker Pages.Clicker.init _ -> NotFound
init : {} -> Url -> Nav.Key -> { model : Model, command : Cmd Msg }init _ url key = { model = { page = pageFromUrl url , navKey = key , clicked = False } , command = Cmd.none }
type Msg = UrlRequested UrlRequest | UrlChanged Url | ButtonClicked | ClickerMsg Pages.Clicker.Msg
update : Msg -> Model -> { model : Model, command : Cmd Msg }update msg model = when msg is UrlRequested urlRequest -> { model = model , command = when urlRequest is Internal url -> Nav.pushUrl model.navKey (Url.toString url)
External url -> Nav.load url }
UrlChanged url -> { model = { model | page = pageFromUrl url } , command = Cmd.none }
ButtonClicked -> { model = { model | clicked = True } , command = Cmd.none } ClickerMsg clickerMsg -> when model.page is Clicker clickerModel -> let { model = newClickerModel, command = clickerCmd } = Pages.Clicker.update clickerMsg clickerModel in { model = { model | page = Clicker newClickerModel } , command = Cmd.map ClickerMsg clickerCmd }
_ -> -- Should never receive this message on other pages. { model = model , command = Cmd.none }
view : Model -> Document Msgview model = when model.page is Home -> { title = "Home" , body = [ Html.a [ Attribute.href "/clicker" ] [ Html.text "Go click the button" ] ] }
Clicker -> Clicker clickerModel -> { title = "Clicker" , body = [ Html.button [ Event.onClick ButtonClicked ] [ Html.text (if model.clicked then "You clicked me!" else "Click me!" ) ] [ Pages.Clicker.view clickerModel |> Html.map ClickerMsg ] }
NotFound -> { title = "Not Found" , body = [ Html.text "Can't find that url" ] }
subscriptions : Model -> Sub Msgsubscriptions _ = Sub.none