A different API design for elm web that removes the need for `Msg` and `update`

Here’s how a counter would be implemented using this approach as a proof of concept:

module Main exposing (..)

import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)



-- MAIN


main =
  Browser.sandbox { init = init, view = view }



-- MODEL


type alias Model = Int


init : Model
init =
  0



-- UPDATE


increment : Model -> Model
increment model =
  model + 1


decrement : Model -> Model
decrement model =
  model - 1



-- VIEW


view : Model -> Html Model
view model =
  div []
    [ button [ onClick decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick increment ] [ text "+" ]
    ]

Here is how this is implemented with elm’s current API design.

I was wondering what people think of this design. To me it seems simpler.

Hi!

It feels like most people make this discovery at some point when learning Elm. You get that heureka moment where you realize that you can use any type as message, including the same type as the model! (update = \msgAkaNextModel _ -> msgAkaNextModel)

It can be useful occasionally (we use it for a few forms at work – you can use it for just a part of your app!), but having a traditional message type has its benefits too.

  • You can look at the message type and see everything that can happen in the app at a glance.
  • You can use Debug.log or the debugger and understand what’s going on – you see messages with descriptive names, instead of new models that you need to diff.
  • Elm has a performance optimization where if messages happen quicker than the browser can draw (such as mouse moves), Elm skips drawing after each of them and draws at the next “animation frame” instead. However, with your approach that can result in stale events, because drawing also involves updating event listeners.
  • I suspect compiler error message might be worse with your approach, due to inference.
1 Like

I don’t think this is totally accurate.

The stale events in my ellie are caused by passing the wrong version of the model. If I rewrite your code a little we to look like this:

view : Model -> Html Model
view viewModel =
  div []
    [ button [ onClick (\eventModel -> eventModel - 1) ] [ text "-" ]
    ]

You can see that we have two versions of the model, viewModel and eventModel. If in the lambda eventModel -> eventModel - 1 we were to use the view model instead so eventModel -> viewModel - 1, we could now get stale events, since we used the version of the model that can become stale by the time the event happens.

But I don’t think this breaks any optimizations, its just that doing it all in the view can lead to confusion about which model to use, if you are not careful. The optimisation still runs, but as @lydell notes, the event loop can run faster than the view, so the view will not be re-run on each and every event to supply a fresh version of the model as viewModel.

I don’t use the time travel debugger, and I sometimes think this pattern would be worth exploring on a larger project to get a feel for whether it would work out well or not. Msg does at least act as documentation to some degree.

Turning all events into Msgs is a form of defunctionalization, and its worth reading up what defunctionalization can be used for to understand the trade offs.

I think it is accurate :slight_smile: Here, check it out: https://ellie-app.com/wjyZ2kmgJvza1
It’s your Ellie again, but updated to Elm 0.19.1, and with a boolean you can flip to switch to synchronous rendering – which then gets rid of the stale updates.

On the flip side, I don’t think your explanation is entirely accurate. I don’t see how there could be different models available in vieweventModel and viewModel.


I also noticed that in @godalming123’s suggested API, the event handlers get passed the model implicitly. So I guess the runtime could pass the correct one regardless. But on the flip side, I have no idea how the runtime could implement that.

The two different model versions eventModel and viewModel are literally here in this code:

view : Model -> Html Model
view viewModel =
  div []
    [ button [ onClick (\eventModel -> eventModel - 1) ] [ text "-" ]
    ]

We are describing the same thing, its just that I felt your explanation was confusing. “stale events, because drawing also involves updating event listeners”, is the part that I found confusing. Its actually the programmer confusing model versions that causes stale events in my opinion.

That is why I presented it as 2 model versions.

viewModel is the state of the model when the view was rendered.
eventModel is the state of the model when the event was processed.

If you are not very careful you can confuse them, and capture stale state from the time the view was rendered, and not from the time the event happened.

If you do not confuse them, you will avoid stale events. But having 2 model versions in the same piece of code can make confusing them easier than if you split your view and update up, and deal with just 1 model version in each.

What’s an example of this? What is there to confuse? I’m having trouble seeing this.

Here’s what happens in the Ellie.

  1. init sets model.version to 0.
  2. Sync: Elm creates the initial DOM, and adds mouseenter and mouseleave event listeners to the blue and red areas, that produce the message Event 0.
  3. Async: The user moves the mouse from the white to the blue area.
  4. Sync: mouseenter fires on the blue area, with message Event 0.
  5. Sync: Elm calls update. It updates model.version to 1.
  6. Async: Elm calls view on the next animation frame and updates the DOM: It updates the event listeners for mouseenter and mouseleave on the blue and red areas to produce the message Event 1.
  7. Async: The user moves the mouse from the blue to the red area.
  8. Sync: mouseleave fires on the blue area, with message Event 1.
  9. Sync: Elm calls update. It updates model.version to 2.
  10. Sync: mouseenter fires on the red area, with message Event 1 (because the DOM hasn’t been updated yet, since we are waiting for the next animation frame).
  11. Sync: Elm calls update. Since the number in the message (1) isn’t the same as model.version (2), it updates model.staleCount to 1.
  12. Async: Elm calls view on the next animation frame and updates the DOM: It updates the event listeners for mouseenter and mouseleave on the blue and red areas to produce the message Event 2. It also updates the text below the red area to “Stale Event Count: 1”.

So there are no models to confuse, unless I’m (still) missing something. It’s all about when Elm updates event listeners on the page.

Let’s say the stale events in the Ellie did happen because the programmer confused two models. Then how can flipping False to True fix the problem? Only un-confusing the code should fix it, right?

I’ve expressly made the mistake on [this ellie](https://ellie-app.com/wjMWxXbNFdga1). Your notion of stale events is just about using the wrong model when creating the event.

1 Like

Flipping False to True fixes it with synchronous rendering - I must admit this was a little surprising to me, as I thought both events would be triggered before the view gets updated. I am not sure how this works internally in Elms runtime - is it just the raw mouse event that gets queued and the Elm code to turn it into a Msg is not invoked until after the next view cycle, so comes out as the correct Msg? Or is it that the entire DOM was updated before the second event even happened?

But it can also be fixed without needing synchronous rendering, just by not capturing the stale version of the model.

Perhaps this code explains better how the model version can be confused:

view : Model -> Html Model
view model =
  div []
    [ button [ onClick (\_ -> model - 1) ] [ text "-" ] -- Wrong model!!
    ]

Do you see it now? I mistakenly used the variable name model in my update function, and by doing so inadvertently captured the state of the model from a time earlier than when the actual update function is run.

If I replace the update function with \m -> m - 1, no need for synchronous rendering as no stale state will be captured. Now my update function is not depending on the absolute state of the model at a particular moment in time, but is a relative delta to the model to be applied at update time.

It is easy to confuse the two, no? At any rate, we are both right, just for different reasons.

I used something like this in my port of the Elm compiler. As a Haskell program, it makes extensive use of the IO monad. The update function in my Elm Reactor like UI looks like

update : Msg → Model → ( Model, Cmd Msg )
update msg model =
    case msg model of
        ( IO.Pure (), newModel ) →
            ( newModel, Cmd.none )

        ( IO.ImpureCmd cmd, newModel ) ->
            ( newModel, cmd )

        ( IO.ImpureCont cont, newModel ) ->
            update (cont identity) newModel

But of course, this is anything but a normal Elm program :slightly_smiling_face:

The Ellie has a few compilation errors, but I fixed them: https://ellie-app.com/wjY882jsLT6a1. If I understand this version of the Ellie correctly, it shows how to do “update in view” but avoid the problem with stale data by having the messages be functions that take non-stale model as input: onMouseEnter <| Event (\eventVersion-> eventVersion + 1). Reminds me of setState in React, where you can optionally do setState(latestValue => latestValue + 1) instead of setState(value + 1).

Now we finally have two models that you can confuse! The one given to view, and the one given to the message functions. Thanks! I think this is what Rupert tried to show all along, but I didn’t understand how it fit together without a full, compiling example.

I’ve never even thought of using functions in messages before. That might come in handy some time! But if you do, that’s when you can mix up two references to models. And I now realize that this is probably what the original post tried to show? I just didn’t get it, because the original post didn’t call it out, and it doesn’t show how the proposed API could be implemented – especially update is missing.

DOM events are super synchronous, and so is Elm’s runtime mostly. The browser fires the first mouse event (mouseleave), and the first event listener for it. Let’s say that’s Elm’s event listener. Elm runs its whole update + view + DOM diffing/patching (since we’re talking about the sync rendering case here) within the event listener. So the whole DOM including event listeners is updated within the event listener for the first mouse event. The browser then continues, firing more event listeners for the same mouse event if any. Then it triggers the next mouse event (mousenter), and the same thing happens.

A fun thing is if you dispatch another DOM event inside an event listener, that event isn’t queued – it’s fired straight away, and when it’s fully handled the browser continues with the original event. Hope that gives a good picture of it.

If Msg is a function, all update does is apply the function:

type alias Msg = Model -> Model

update msg model = 
    msg model

Or in a more complete version with side effects:

type Msg = Msg (Model -> (Model, Cmd Msg)) -- Needs to be a custom type to be recursive

update (Msg msg) model = 
    msg model

It is kind of neat in the sense that that is all that is needed to implement update instead of a steadily growing case statement.

Typically my update case statement will break out each branch into its own update-like function anyway, named after what it does. So instead of triggering those with Msgs capturing the parameters for each, I would just use them directly in the view or in other update-like functions.

However, I can think of another reason not to do this, which is to do with race conditions in the real world…

Elm is single threaded, and event driven. The real world is parallel. This means that real world events can arrive into your Elm code not necessarily in the same order each time. My favourite example is a dialog box with a “close” button. When the dialog is opened it sends a network request to fetch some data to show in the box. When the data arrives it puts the dialog box into a certain state depending on the data. But if the user clicks close before the data arrives, we do not want to transition into that new dialog box state, because the box has already gone! Network call and close click can give rise to a race condition, even though Elm is single threaded.

So we use a state machine to marshall real world events into a sequence of state machine transitions that we consider to be legal states of the program, and eliminate the spurious transition that would resurrect the closed dialog if data arrives later.

type Model 
    = Closed
    | OpenWaitingForData
    | OpenGotData

type Msg
    = DataFetched
    | CloseClick
    | OpenClicked


update msg model = 
    case (model, msg) of
        (Closed, OpenClicked) -> OpenWaitingForData

        (OpenWaitingForData, CloseClicked) -> Closed

        (OpenGotData, CloseClicked) -> Closed

        (OpenWaitingForData, DataFetched) -> OpenGotData

        _ -> model

If Msg was just a function, we would have to model the states as branches in the view instead, to limit state transitions from events happening only in certain situations. Doable, but update like above often does a very good job of modelling the state machine transitions directly in an easily readable format.

2 Likes

Just a couple of links to add to the conversation here with bugs caused by stale messages, which are essentially old the model (or parts of it) in the message type. The general pattern of this kind of bug is that you’re calculating in the view what the model will become if that event happens.

Here is the first link: https://blog.realkinetic.com/the-trickiest-elm-bug-ive-ever-seen-988aff6cbbd7

My own experience with this was related to browser auto-fill of forms, because the new ‘registration form’ was calculated in the view but it basically only recorded the last one (because it doesn’t get a chance to run the view code in between each form field update).

2 Likes

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.