How do you organize `update` with custom type Models?

Hello everybody!
Let’s say I have this Model and Msg:

type Model
  = LoggedOut
  | LoggedIn
  | Synced {...}
  | Error String

type Msg
  = GotLoggedIn
  | GotMainPage {...}

How do you write the update function in this case?
I see a few options: (note that for the sake of brevity I don’t extract inner functions into top-level definitions, even though that’s what I’d normally do)

1. Group by Model, then by Msg

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

    LoggedIn ->
      case msg of
        GotLoggedIn -> ...
        GotMainPage _ -> ...

    Synced _ ->
      case msg of
        GotLoggedIn -> ...
        GotMainPage _ -> ...

    Error _ ->
      case msg of
        GotLoggedIn -> ...
        GotMainPage _ -> ...

2. Group by Msg, then by Model

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    GotLoggedIn ->
      case model of
        LoggedOut -> ...
        LoggedIn -> ...
        Synced _ -> ...
        Error _ -> ...

    GotMainPage _ ->
      case model of
        LoggedOut -> ...
        LoggedIn -> ...
        Synced _ -> ...
        Error _ -> ...

3. Only handle what makes sense, wildcard the rest

(notably used by elm-spa-example)

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case (msg, model) of
    (GotLoggedIn, LoggedOut) -> ...
    (GotMainPage _, LoggedIn) -> ...
    (GotMainPage _, Synced _) -> ...
    _ -> ...

The last one seems like it leads to least amount of boilerplate, but I fear the wildcard would throw out the ability to find out what’s happening and to debug the program fast. The variants without wildcard make you at least think about each possibility before writing a new -> noop case in each.

What do(/would) you do? Maybe some hybrid - 1 or 2 with wildcards?

1 Like

I would choose option 1

Am I correct that LoggedOut model could get only GotLoggedIn, LoggedIn and Synced models could get only GotMainPage, and Error should get no messages? And am I correct that Synced is logged in with main page, and Error is logged in without main page?

1 Like

I’d use 3.

As long as all messages can actually occur for all states of the Model, I would only use wildcards for the Msg not the Model.

Once there are actually a lot of Msgs that only work for a specific state I would start giving every state its own Msg type and extract it to its own update function. In that case I would use a wildcard for both Msg and Model.

1 Like

small note:
Version 2 with wildcards is the chosen method for package.elm-lang.org.

The particulars of this example shouldn’t matter, and eg. Error could be transitioned to from any other Model, but let’s say yes. Does your preferred strategy change with the shape of the FSM?

Hmm, so there are some global Msgs and then some Msgs specific to the Model/Page cases

I would go with second option. Because Msg is the language update function speaks. And I would create helper functions to check if user is logged or not.

isLoggedOut : Model -> Bool
isLoggedOut model =
  LoggedOut -> True
  LoggedIn -> False
  Synced _ -> False
  Error _ -> False

isLoggedIn : Model -> Bool
isLoggedIn =
  not isLoggedOut

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    GotLoggedIn ->
      if isLoggedOut model then
        — Process changes, because only logged out user can log in
      else
        ( model, Cmd.none)

    GotMainPage _ ->
      if isLoggedIn model then
        — Process changes, because only logged in user can get main page
      else
        ( model, Cmd.none)

Implementation maybe different if a message should be processed differently for different model states (e.g. update process for GotMainPage is different for different model states)

In practice I seem to most often do 3…

Note that 1 and 2 won’t remove the need to do wildcards. If you have states which cannot process some messages, you just end up with wildcards beneath each of those states.

So long as you design so that the wildcard is always a no-op, I don’t feel it is such a problem to lose exhaustiveness checking.

For 1, 2:

_ -> (model, Cmd.none)

or for 3:

(_, _) -> (model, Cmd.none)

With (2) you get exhaustiveness checking for messages which is, in my opinion better than exhaustiveness for model (1) .

4 Likes

Yeah, in my mind (and I’m sure many of all your minds too) the Msg represents something that happens. So having one big Msg type for all types of pages or stages (LoggingIn, LoggedIn, …) is strange and implausible because its like saying “Anything can happen at any time”, even tho its implicit that different pages and stages have different UIs and different behavior.

So if theres some set of “things that can happen” which are clearly limited to particular chunks of your application, I would make a Msg type for that chunk of the application. There are things that truly do happen across all pages / stages of your application, so those should go in the top level Msg, but those are the minority case, not that majority case.

(All of this within whats practical and reasonable. When it comes to Msg you always have to handle weird cases, and also those weird cases always end up being more possible than it appears.)

2 Likes

My feeling is this, too. I do this sometimes:

update msg model =
    case msg of
        DoThis ->
            case model of
                WhenItIsA data ->
                    doThis data

                _ ->
                    pure model
        DoThat ->
            case model of
                WhenItIsB data ->
                    doThat data

                _ ->
                    pure model

Point is extracting actual update into private functions (or, use sub-components’ update functions and wire results into root model with private functions. Like this in package site), and keep root update looks simple.
update is, IMO, kind of “router” of application logic, and keep “router” looks simple is important for readability.

First, this is strictly cultural question, how team want to work consistently. I guess there is no One Right Way in this case. My gut says that you should match against model first, because, you are modifying it, and you know that in some cases some of stray events just don’t make sense. Hence I would go with 1.

But one also can argue that modeling both of messages and model is off, because (kough*) you are representing impossible combinations of events and states.

Now solution to it is knowing exact nature of interaction. I would carry more information in the messages.
From I can tell there are two kind of events (messages), the one that have the User and one that don’t:

I will free ball just to illustrate the point:

type Model 
  = A 
  | B SomeData 
  | C Error

type Msg 
   = SetC Error
    | MoveToB SomeData 
    | SimpleA 

update msg model =
   case msg of
     SetC error -> C error
     MoveToB data -> B data
     SimpleA -> A 

which brings me to the point, maybe if you are modeling FSM, role of messages is not to describe what happened but how FSM should transition, and logic about which transition you should perform should be in message generating function, ie view. Helpful thing here would be use of Json Decoders because you can mute event with Json.fail if you think there is no point of updating.

view model = 
 case model of 
  A -> div [] 
    [ button [ onClick <| SetC (NastyError) ] [ text "Make error" ] 
   , input [ onInput (toSomeData >> MoveToB) [] 
   ] 
  B data -> Debug.todo " : D " 
1 Like

Do to the asynchronous nature of event processing in Javascript and Elm, the possibility of non-determinism is always there.

For example, suppose you have an info pop-up dialog box that fetches some information from the server. This dialog also has a ‘Dismiss’ button on it that the user can hit once they have seen it and are ready to move on. Now suppose that the server call is taking too long, so the user gets bored and hits the button before the info is loaded. After this, the data does arrive from the server and triggers a DataLoaded event, but the application is now not in the correct state that it can show this information. The best course of action is probably just to ignore it.

So the correct model for an elm application is not an FSM, but a non-deterministic FSM. By which I mean an FSM which corrales a non-deterministic event stream into well-behaved sequence of state transitions.

This is also why the logic cannot be in the view function - because the view is only updated on the animation frame. It can lag behind the state machine, and end up creating transations that use stale state.

Here is the Ellie I wrote to illustrate how stale state can happen: https://ellie-app.com/yVC9wPk33xa1

I understand totally the case and you are right, sometimes you have to eat those impossible messages. :slight_smile:

I used to ask myself this a lot, but over time I’ve found that most places where I originally thought to have the Model be a union type ended up being better by refactoring the Model to be a record instead.

For instance, instead of having

type Model
    = LoggedOut
    | Authorizing AuthToken
    | LoggedIn User
    | DataLoaded User Data

I think it turns out better to have

type alias Model =
    { auth : Auth
    , data : Maybe Data
    }

type Auth
    = LoggedOut
    | GotToken AuthToken
    | LoggedIn User

This avoids the issue of having to destructure both model and msg… (or maybe you could argue this is a form of destructuring the Msg first, since some messages would still need to destructure fields of the Model).

I think this also makes the code more robust to refactoring because the handling of each message is only coupled to the fields of the model that are relevant to processing that message, and there’s no page-wide states that all messages are forced to understand.

8 Likes

This has been a big part of my strategy for this situation. It feels better aligned with keeping impossible states impossible, as you said, because you can necessarily only pass that message when all that data is available to pass with it.

Sometimes it seems like it ends up with me having biiig messages in terms of how many args/how much data is in them. Maybe that just means I should be using more records or similar for message arguments or something, though. Haven’t worked out all the kinks yet.

While I’m thinking about it, here’s an example of me making this kind of change.

IMHO it really depends on the app at hand. In most cases I take the (2) approach, but in some cases I use (3).

But I also make extensive use of helper functions to map function over certain states.

Here is example of the app state with tabbed interface:

type Model 
    = ProductsTab ProductsTabData
    | ProductsTabSubmitting ProductsTabData
    | UsersTab UsersTabData
    | UsersTabSubmitting UsersTabData

As illustrated in this example application can be in different states while holding the same data.

When working with model like this I create helper functions:

mapProductsData : (ProductsTabData -> ProductsTabData) -> Model -> Model

mapUsersData : (UsersTabData -> UsersTabData) -> Model -> Model
2 Likes

Obviously, lots of opinions, but for SURE I would do #2, and also convert the Model to a record / type alias instead of a custom type, e.g.

type alias Model = 
  { userLogin : LoginStatus
  , synced : SyncRecord 
  , maybeError : Maybe String
  }

The basic idea is that the Elm event loop takes a message and a model as input, updates a portion of the model based on the message, then returns a modified model and a possible command (or command list) as output, e.g.

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    UpdateLogin newLoginState ->
        ( { model | userLogin = newLoginState }, Cmd.none )

etc...

Also, I’d avoid wildcards.

At least, that’s how we do it. (Large production codebase)