The Module Pattern is a pattern I've discovered in Elm for dealing with some of the inconveniences of nested TEA1. As an added bonus, it can make component-type modules a little more straightforward to implement.
Note: I don’t recommend building an application entirely out of nested TEA. In fact, I recommend nesting TEA as infrequently as possible; but sometimes it seems to be inevitable - as much as again when you’re working on a large codebase that isn’t entirely yours. Handle with care, proceed with caution, terms and restrictions may apply, see store for details, something something California okay anyway moving on.
People tend to nest TEA when they "want components"; a common scenario is that a codebase will include a form module - let's call it Form
- that has some parameterized types and is designed to be "hosted" within another module (usually a "page"). The intent of the author of the Form
module was that, while a form is inherently stateful, that state should be opaque, and any interactions with the form's state should be mediated through its own view
and update
functions, and then mapped back to the host module.
So let's imagine that we have our Form
module, and let's imagine that we want to add a form to a module called Signup
, that describes a signup flow. You'd expect to run into something like this:
Form
module Form exposing (Model, Msg, view, update, init)
import Html exposing (Html)
type Model = Model ...
view : Model -> Html msg
view model =
...
init : ( Model, Cmd Msg )
init =
...
type Msg
= ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
...
Signup
module Signup exposing (Model, Msg, view, update, init)
import Html exposing (Html)
type Model =
Model
{ formModel : Form.Model
...
}
view : Model -> Html msg
view ( Model model ) =
Html.div []
[ Form.view model.formModel
|> Html.map GotFormMsg
...
]
init : ( Model, Cmd Msg )
init =
Form.init
|> (\(formModel, formCmd) ->
( Model
{ formModel = formModel
, ...
}
, Cmd.batch
[ Cmd.map GotFormMsg formCmd
, ...
]
)
)
type Msg
= GotFormMsg Form.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg (Model model) =
case msg of
GotFormMsg formMsg ->
Form.update formMsg model.formModel
|> (\( formModel, formCmd ) ->
( Model { model | formModel = formModel }
, Cmd.map GotFormMsg formCmd
)
Let's look at all of the plates we just had to boil:
- We had to call
Html.map
once, andCmd.map
twice - We had to destructure the result of
Form.init
andForm.update
and map them to our host module'sinit
andupdate
- When we mapped, we had to map to
GotFormMsg
three times
And this is a trivial example; what if we had to pass outside params to Form.init
, but not to Form.update
? What if Form.view
and Form.update
both needed access to a Session
that was defined outside of Signup
, that needed to be handed in? Bucket-brigading dependency params around to multiple callsites in the same module can quickly become exhausting.
Moreover - what if Form
was one of those super-cool modules that had a Config
type that was constructed applicatively, with a dozen exposed functions to manage its various options - and then another score of exposed functions to actually implement bits and pieces of the module?
And finally - for every module within which you choose to implement Form
, you have to do all of these mapping motions over and over and over again. As Form
grows, if the parameters required to construct it and implement it change, you have to change every single callsite where every MVU touchpoint is accessed, in every single module that uses Form
.
Oh, and I know that I just said “And finally -“ - but one more thing - maybe the worst thing? - there’s no clear delineation between “the parts of this module that are related to interop with another module”, and “the parts of this module that are actually dealing with business / domain logic”. In fact, it may be the case as this application grows, that your inter-module communication gets woven throughout the rest of your domain logic, sprinkled in haphazardly catch-as-catch-can - and it can become remarkably hard to disentangle, later on down the line.
Now comes the Module Pattern. The idea is that you can create a type that represents your "module" - really, your model / view / update - and expose that; by parameterizing a function that "initializes" your module, you can pass in all of those mapping params in one centralized place.
Let's do it for Form
:
Form
module Form exposing (Model, Msg, Module, init)
import Html exposing (Html)
type alias Module msg model =
{ view : model -> Html msg
, update : Msg -> model -> ( model, Cmd msg )
, init : ( Model, Cmd msg )
}
init :
{ toModel : model -> Model -> model
, fromModel : model -> Model
, toMsg : Msg -> msg
} -> Module msg model
init { toModel, fromModel, toMsg } =
{ view =
\model ->
view (fromModel model)
|> Html.map toMsg
, update =
\msg model ->
update msg (fromModel model)
|> ( \( formModel, formCmd ) ->
( toModel model formModel
, Cmd.map toMsg formCmd
)
, init =
Tuple.mapSecond GotFormMsg init_
}
type Model = Model ...
view : Model -> Html msg
view model =
...
type Msg
= ...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
...
So what does that do for our callsites in Signup
?
Signup
module Signup exposing (Model, Msg, view, update, init)
import Html exposing (Html)
type Model =
Model
{ formModel : Form.Model
...
}
formModule : Form.Module Msg Model
formModule =
Form.init
{ toModel =
\(Model model) formModel ->
Model { model | formModel = formModel }
, fromModel =
\( Model { formModel } ) -> formModel
, toMsg = GotFormMsg
}
view : Model -> Html msg
view ( Model model ) =
Html.div []
[ formModule.view model
...
]
init : ( Model, Cmd Msg )
init =
( Model { formModel = Tuple.first formModule.init, ... }
, Cmd.batch [ Tuple.second formModule.init, ... ]
)
type Msg
= GotFormMsg Form.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg (Model model) =
case msg of
GotFormMsg formMsg ->
formModule.update formMsg (Model model)
It's nice, it's simple, and it gives you some extra leverage:
- You know for certain what interfaces you need to satisfy in order to successfully interop with
Form
- if something is a parameter toForm.init
, now, you absolutely must have it in-scope to be able to create aForm
. - You can think in terms of "the module that you're in" all of the time, unless you're implementing
init
- yourForm
module'sview
andupdate
can be written in terms ofForm
, and yourSignup
'sview
andupdate
can be written in terms ofSignup
. - You can repeat this pattern virtually everywhere, so other developers in your codebase will always know that to implement a module, you always start at
init
and expect aModule msg model
that they can use to represent that module's data and behaviors, without having to think too hard about how to map data into and out of it. - You can centralize all of your module’s initialization, the definition of all of its required dependencies and interfaces, and its interop all in one place; as your application grows, your
init
will only change as requirements for inter-module communication change, and the rest of your module will only change as business / domain requirements change.
Note, too, that you don't have to return a view
from your module initialization; you can return data - a function that takes an event and returns HTML, so that you can implement (for instance) form submission via any kind of onClick
-able element - or maybe just a list of values of a type that your module is creating, so that you can render them however you want.
In my humble opinion, the less frequently you feel as though you need nested state in an Elm application, the better your life is going to be; but if you must do it, this seems to be the cleanest way to go about it.
-
“Nested TEA” is a common pattern in Elm development, where “TEA” - The Elm Architecture, also known as Model-View-Update - is used as the primary abstraction for organizing the overall application. Nested TEA is so-called because most major parts of the application are segmented off into their own modules / module namespaces, with internal model / view / update types and functions that are “nested” inside of other models, other views, and other updates. Whether or not this is actually the best way to build Elm applications may be up for debate; but the fact remains that this is a popular style, and that if you’re a working Elm developer, you will likely get paid actual money to deal with it. ↩
Top comments (4)
It's an interesting idea. My co-worker implemented something similar, see Component and Field. We've used
Field
to build out our form fields here, here, and here.To be honest, I didn't like using it much at the time. It's an acquired taste. Though, if you think of it like interfaces (from Java) or type classes (from Haskell) you get the point of why you may want to do it.
Now you have me considering if I should revisit this idea and use it in one of my personal projects where I have a lot boilerplate between my main module and the page modules it orchestrates. 🤔
Really great thinking here! Indeed, it seems I can often get by without nested state, and agree that that's ideal - but it sometimes feels quite necessary, and I've never found a good way of doing it that doesn't explode in complexity and boilerplate like you describe.
You solution really clears it up! I'm excited to try it out. Which is a nice change from the usual feeling of vague dread when it comes to implementing nested Elm state.
(created an account here just to say this. Thanks for writing this up!)
Heyyy, alright! So glad this helped. Let me know how it goes - you can find me rattling around Elm Slack from time to time, as well as all of the Discords (as lambdapriest).
I’ve been seeing how far I can push this design, and as of today I’m pretty sure that I’ve cracked a nice way to do middleware in Lamdera… maybe I’ll muster up the time and motivation to write about that soon, too.
The title just reminds me of a Deadmau5 album...