DEV Community

John Pavlick
John Pavlick

Posted on

For lack of a better name, I’m calling it “The Module Pattern”.

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 =
    ...
Enter fullscreen mode Exit fullscreen mode

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
                   )
Enter fullscreen mode Exit fullscreen mode

Let's look at all of the plates we just had to boil:

  • We had to call Html.map once, and Cmd.map twice
  • We had to destructure the result of Form.init and Form.update and map them to our host module's init and update
  • 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 =
    ...
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 to Form.init, now, you absolutely must have it in-scope to be able to create a Form.
  • You can think in terms of "the module that you're in" all of the time, unless you're implementing init - your Form module's view and update can be written in terms of Form, and your Signup's view and update can be written in terms of Signup.
  • 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 a Module 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.


  1. “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 (1)

Collapse
 
dwayne profile image
Dwayne Crooks

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. 🤔