What are your thoughts on the translator pattern for child-parent communication?

I’ve been implementing RealWorld in Elm from scratch and one architectural design decision I made early on was adopting my own version of the translator pattern for child-parent communication.

My version of the translator pattern leads to an update function, for the login page, that looks as follows:

type alias UpdateOptions msg =
    { ...
    , onLoggedIn : User -> msg
    , onChange : Msg -> msg
    }

type Msg
    = ...
    | SubmittedForm
    | GotLoginResponse (Result (Api.Error (List String)) User

update : UpdateOptions msg -> Msg -> Model -> ( Model, Cmd msg )
update options msg model =
    case msg of
        ...

        SubmittedForm ->
            validate model
                |> V.withValidation
                    { onSuccess =
                        \{ email, password } ->
                            ( { model | errorMessages = [], isDisabled = True }
                            --
                            -- 1. Attempt to login.
                            --
                            , Login.login
                                options.apiUrl
                                { email = email
                                , password = password
                                , onResponse = GotLoginResponse
                                }
                                |> Cmd.map options.onChange
                            )
                    , onFailure =
                        \errorMessages ->
                            ( { model | errorMessages = errorMessages }
                            , Cmd.none
                            )
                    }

        GotLoginResponse result ->
            Api.handleFormResponse
                (\user ->
                    ( init
                    --
                    -- 2. On successfully logging in you have to tell your parent, Main in this case,
                    --    so that they can do whatever they need to do on log in. As the login page
                    --    I don't care about that stuff. That's for my parent to handle.
                    --    In the case of Main, it wants to record the user's token and redirect the
                    --    user to the home page.
                    --
                    , Task.dispatch (options.onLoggedIn user)
                    )
                )
                model
                result

I like the solution that unfolds and I think I’m going to continue using it but there is at least one bit that maybe contentious which is the use of Task.dispatch.

dispatch : msg -> Cmd msg
dispatch =
    Task.succeed >> Task.perform identity

With Task.dispatch I avoid making update have the return type ( Model, msg, Cmd msg ) and instead I push the parent message into the Elm runtime which eventually finds its way back to the parent’s update function.

Here’s the relevant parts of Main:

updateLoginPage : LoginPage.Msg -> SuccessModel -> ( SuccessModel, Cmd Msg )
updateLoginPage pageMsg subModel =
    case subModel.page of
        Login pageModel ->
            let
                ( newPageModel, newPageCmd ) =
                    LoginPage.update
                        { apiUrl = subModel.apiUrl
                        --
                        -- 1. This is saying to notify me when you've successfully logged in via the login page.
                        --
                        , onLoggedIn = LoggedIn
                        , onChange = ChangedPage << ChangedLoginPage
                        }
                        pageMsg
                        pageModel
            in
            ( { subModel | page = Login newPageModel }
            , newPageCmd
            )

        _ ->
            ( subModel, Cmd.none )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ...

        LoggedIn user ->
            --
            -- 2. Handle a successful login.
            --
            loginUser user model

        ...

loginUser : User -> Model -> ( Model, Cmd Msg )
loginUser user =
    withSuccessModel
        (\subModel ->
            ( { subModel | viewer = Viewer.User user }
            , Cmd.batch
                --
                -- 3. Save the user's token and redirect to the home page.
                --
                [ Port.Action.saveToken user.token
                , Route.redirectToHome subModel.key
                ]
            )
        )

N.B. If I used ( Model, msg, Cmd msg ) then loginUser would be handled in the updateLoginPage function.

I’d love to get feedback on this design so I can be more objective about this architectural decision, i.e. this take on the translator pattern.

P.S. I make use of this version of the translator pattern in dwayne/elm-debouncer. I liked it so much for that library that I decided to try it out in this app as well. At work we adopted a version of the OutMsg pattern and I didn’t much like the code it lead you to write.

3 Likes

One thing I like about the translator pattern is that the UpdateOptions converts your Msg to an abstract msg for interpretation by the parent (caller). This means you can keep your Msg opaque.

Also if you want to pass in an event to your module, you should avoid doing so by passing a Msg to its update. Not all update-like things in Elm have to be called update, just make a function in the module that handles the event. No need to defunctionalize everything through an exposed Msg type, the Msg type is an internal only thing for a module, for taking care of its local side-effects only.

Here is how you can avoid the use of dispatch. I call this Protocol pattern, because that name was suggested on Slack, and it seemed ok and I could not think of a better one. So in this example I renamed your UpdateOptions to Protocol:

type alias Protocol submodel msg model =
    { toMsg : Msg -> msg

    -- Where to continue after an update.
    , onUpdate : ( submodel, Cmd msg ) -> ( model, Cmd msg )
    , onLoginOk : Credentials -> ( submodel, Cmd msg ) -> ( model, Cmd msg )
    , onLoginFail : ( submodel, Cmd msg ) -> ( model, Cmd msg )
    }


type Msg
    = ...
    | SubmittedForm
    | GotLoginResponse (Result (Api.Error (List String)) User


update : Protocol Model msg model -> Msg -> Model -> ( model, Cmd msg )
update protocol msg model =
    case msg of
        ...

        SubmittedForm ->
            processForm model -- yields a (Model, Cmd Msg)
                |> Tuple.mapSecond protocol.toMsg -- yields a (Model, Cmd msg)
                |> protocol.onUpdate -- nothing special to tell the parent about, just a normal update
  
        GotLoginResponse (Ok loggedInProps) ->
            processLoginOk loggedInProps model -- yields a (Model, Cmd Msg)
                |> Tuple.mapSecond protocol.toMsg -- yields of (Model, Cmd msg)
                |> protocol.onLoginOk loggedInProps.credentials -- tell the parent we logged in successfully

{-| Update-like function requesting an immediate log out. -}
logOut :  Protocol Model msg model -> Model -> ( model, Cmd msg )
logOut protocol model =         

In the parent you need to build an instance of the protocol and use it whenever you call update-like things on the child:

type alias Model = 
    { child : Login.Model
    , ...
    }

childProtocol : Model -> Login.Protocol Login.Model Msg Model
childProtocol model =
    let
        setChild child =
            { model | child = child }
    in
    { toMsg = LoginMsg
    , onUpdate = U2.map setChild
    , onLoginOk = \cred -> U2.map setChild >> U2.andThen (processLogin cred)
    , onLoginFail = ...
    }


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        LoginMsg innerMsg ->
            Login.update (childProtocol model) innerMsg model.child

No “message” passing at all between the modules. It is all done through higher-order functions. Love this, because Elm is a functional language, not an actor-model language.

I like that this avoids falling into the pit of thinking of Elm as an actor-model like thing, with message passing between encapsulated components. I only create defunctionalized Msg types, which are opaque, and only for processing side effects.

This pattern does not require you to return a triple. The usual (model, Cmd msg) tuple can be used everywhere in your application without exception. This is great for railway programming based around map, andThen, andMap and so on over the (model, Cmd msg) structure. For example, I have this implementation Update2 - elm-update-helper 2.3.0 but I think there are a few others. I might now delete the Update3 module, since I no longer have any need for it and consider it bad practice. I do not need to constantly convert between U2 and U3 formats.

You can find an example of the protocol pattern I built when experiementing with it here: GitHub - rupertlssmith/elm-protocol: Trying out the Elm config pattern with continuations.. Coincidentally, this is also an authentication example.

3 Likes

We essentially use the translator pattern at work, though it’s wrapped in an opaque type so that most of our updates look like

{ model | ...changes }
    |> Page.update
    |> Page.Update.withQuery ...
    |> Page.Update.withMutation ...
    |> Page.Update.withOtherEffect ...
    |> Page.Update.mapMsg options.toMsg
    |> Page.Update.mapModel options.toModel
    |> Page.Update.withMsg (options.onLoggedIn user)

(not all of those are used together, just put them there as examples of some)

The wrapping in an opaque type is partially to support an Elm Spa/Elm Land like Effect module, and also makes pipeline style building easier than with tuples.


I do like the look of the protocol approach that @rupert has though. I like that it removes the Task.dispatch style code.

One of the problems I have had with Task dispatch, is that in some situations it can make the UI laggy. I used ResizeObserver and wrapped it as a custom type. Then I could listen for resize events occurring when contenteditable text overflowed a div within a div that represents a sticky note on a drawing. In response to that I make the sticky note grow in size. The editor code was in a child module and the note sizing code in the parent making use of the re-usable editor module. With dispatch the resize would go via the Elm event queue, and miss the next animation frame. :frowning: With the functional approach, it all completed within a single TEA update cycle and there was no visible lag :champagne:

Of course I did not have to structure my code this way, but the intention of having re-usable editor was there before I got to the bit where it lagged.

Generally for non UI and time sensitive stuff dispatch will be ok. For example, dispatch after a network event - well who knows when the network is going to respond, so adding a dispatch makes no noticeable difference. Feels a bit ugly when you have to use it though!

Experiencing a mouse lag on right button drag at the moment, so I started looking into what messages I have flying about, and remembered that the way this works I have 2 messages for each translated pointer event.

I used Task.Extra.messsage (same as dispatch) in my pointer package. I think if I rewrite this in the protocol pattern style, I can get rid of that. I don’t know if it will fix my mouse lag issue though…!

I’ve used your translator pattern, @rupert, twice in the past week to resolve circular references. I feel like it’s going to make our code easier to reason about as well.

I assume this is possible because the “interface” makes it possible to apply the dependency inversion principle. The interface being:

type alias Protocol submodel msg model =
    { toMsg : Msg -> msg

    -- Where to continue after an update.
    , onUpdate : ( submodel, Cmd msg ) -> ( model, Cmd msg )
    , onLoginOk : Credentials -> ( submodel, Cmd msg ) -> ( model, Cmd msg )
    , onLoginFail : ( submodel, Cmd msg ) -> ( model, Cmd msg )
    }

Which means that this module requires a set of follow on update functions, but that those are injected at runtime, rather than being a concrete compile time dependency.

I think if we were to also use the effect pattern in this interface, it could help greatly with module testability. Dependency injection combined with avoiding the use of opaque Cmds would mean that such a module could be easily instantiated in isolation for testing purposes.

@rupert @wolfadex Thank you for your thoughtful comments and suggestions.

I like that too. Whenever my reusable view needs a custom Msg type I now always provide an onChange : Msg -> msg to get that benefit.

I’ll keep this idea in mind but I don’t see myself ever needing or using it. On the face of it, it seems like overkill, hard to use and aesthetically displeasing. If the protocol pattern is the alternative then I’d stick with Task.dispatch.

I had that concern as well, but I’m yet to experience it in practice.

Did you mean time insensitive stuff? Also, I was favouring it for UI stuff as opposed to non-UI stuff. I don’t see why I’d want to do Task.dispatch for non-UI stuff.

The JavaScript DOM gives you an event-driven model for UIs. I have found that Task.dispatch, when needed and which isn’t often, keeps you in that mindset when writing your Elm views.

Let me try to explain with an example from Cells.

Here’s a reusable spreadsheet view: elm-7guis/src/Task/Cells/View/Sheet.elm at f7bf120c2cb1bf0520bdf1921bd063af1aacc3b2 · dwayne/elm-7guis · GitHub. You’re able to double click a cell, enter a formula, and hit Enter. If I didn’t need to update the internal state when the user hit Enter then rather than having enter = PressedEnter I would have had enter = handlers.onInput edit.coord edit.rawInput. The point is that without the internal state update the view would have been event driven so it makes sense to me to keep it event driven even with the internal state update. Otherwise, some aspects of the view would be event driven and others parts would not be.

I thought of it as follows, if it helps. If the DOM was providing me with a UI element for a spreadsheet view, then how would I interact with that element when the user pressed enter into one of its cells. It seemed to me that it’d be something like:

<spreadsheet id="my-spreadsheet">...</spreadsheet>
const spreadsheet = document.getElementById("my-spreadsheet");
spreadsheet.addEventListener("onInput", function (e) {
   // ...
   // do something with e.coord and e.rawInput
   // ...
});

That’s exactly what I get to achieve with Task.dispatch in this case, see here.

I don’t feel like I lose out on testing since I prefer to make my views very dumb and my data model really rich. Just look at the data model for Cells. And, look at the test cases I’m able to cover. For e.g. this test case exposes a performance issue in all the other Cells implementations I’ve tried because they don’t do proper data modeling.

So it seems it isn’t the dispatch at fault here. I’d be interested to learn what was the core issue then.

I meant that to read like non (UI and time sensitive stuff). So in plain English I should probably have written non-UI and non-time sensitive stuff, or just non-UI and time insensitive stuff like you suggested.

Here is an example of when you might use dispatch with the translator pattern when doing non-UI stuff.

Suppose you split off the part of your code that does auth HTTP calls, and also manages (caches) the applications current auth state. So it is not just a set of HTTP calls returning Cmds, but something update-like that returns a (model, Cmd msg).

Now when you get an HTTP response that puts the application into a certian state, say Authenticated, if you want to send an event as a msg back to the caller, you would need to use dispatch to do that.

In terms of performance, it is going to be fine, since the network call is always going to take a lot longer to complete anyway.

Not saying this is the best design to use, but it does illustrate where dispatch might be used in a non-UI situation.

Thanks for the example. I usually consider all that to be UI-adjacent so that’s why I didn’t consider it to be a non-UI example.

I’m not worried about performance. In my experience, the dispatch pattern seems to do fine for UI work as well. I forgot to mention that I used this pattern in:

One thing I have been wondering lately is should domain models built in Elm ever produce Cmd? Or should they be kep side effect free, and not dependant on any side effects?

I was looking at the 2024 game logic you did, Dwayne:

And noticed that you have the game model and logic there “screaming” game logic. Any thoughts on how it might work out building the game model and logic but with no side effects, just pure logic and abstracting “changes” to the model out?

Reason I am thinking about this is that in current project we have domain model and logic, but also run it on front-end and on the back-end, so in the different environments different side-effects will be available. So not hard coding side effects into the highest level domain logic (high-level policy) suggests itself as a strategy for building longer-term stable domain models in Elm.

Yes, use an Effect type. I made a compromise in App.Data.Game for practical reasons (I wasn’t going to interpret the effects in any other way) but everything else in App.Data.* is pure logic with no side effects.

2 Likes

Yes, an Effect type will help - the same Effect could be interpreted differently in different environments.

I was thinking of going one step further than this though - have a domain model that consists of data models and functions to manipulate them, but that never produces any Cmds or Effects. Pure non side effecting code only. Then have other modules that depends on that one, that implement the various use cases.

For example, if it were a game… On the client the domain model would enforce the rules of the game and offer up all the valid next moves. Use case might be to offer the valid next moves to the player to choose from. On the server side it might check that a game replay only consists of valid moves. Use case might be checking game scores are legitimate and there was no cheating, then building a leader board.

I think I know what you mean. I have at least two small applications written in that style and I think the approach scales to what you want.

elm-tictactoe

The game logic is implemented and enforced in XO.Game. It’s completely decoupled from the UI as demonstrated by these tests.

elm-calculator

The calculator logic is implemented and enforced in Calculator. Again, it’s completely decoupled from the UI as demonstrated by these tests.

1 Like

Nice examples Dwayne, thanks. :clap:

1 Like