Skip to content

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:

src/Main.gren
module Main exposing (main)
import Browser exposing (Document, UrlRequest(..))
import Browser.Navigation as Nav
import Html exposing (Html)
import Html.Attributes as Attribute
import Html.Events as Event
import 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 -> Page
pageFromUrl 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 Msg
view 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 Msg
subscriptions _ =
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):

src/Pages/Clicker.gren
module Pages.Clicker exposing (Model, Msg, init, update, view)
import Browser exposing (Document)
import Html exposing (Html)
import Html.Attributes as Attribute
import Html.Events as Event
type alias Model =
{ clicked : Bool
}
init : Model
init =
{ 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 Msg
view 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:

src/Main.gren
module Main exposing (main)
import Browser exposing (Document, UrlRequest(..))
import Browser.Navigation as Nav
import Html exposing (Html)
import Html.Attributes as Attribute
import Html.Events as Event
import 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 -> Page
pageFromUrl 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 Msg
view 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 Msg
subscriptions _ =
Sub.none