Splitting Elm messages

· Allanderek's blog


Elm apps, require that we define a single type, usually called Msg, to host the type of messages that form a major part of the 'The Elm Architecture'. This single type is usually a custom/variant type, and because it must host all of the messages that the app may consume, it can get rather large. This leads to a large update function. I do not think this necessarily a bad thing, but many beginners to Elm baulk at the idea of large functions, and so they seek solutions to break up their Msg type. The basic idea is to make your messages hierarchical, so you have a variant that itself contains a variant. The question is how best to split this up. I'm going to explain why the first instinct in this is usually wrong, suggest a slightly better way, and end up by claiming that the main thing is to remain fluid in your datatypes so that you can best represent whatever the current situation is, rather than cling to an old design for earlier requirements.

The first instinct is usually to group messages together that are somehow related to the same parts of the app. So you think, let's have all the login related messages in a separate type, and then have a single LoginMsg variant which holds it.

 1type LoginMessage
 2    = EmailInput String
 3    | PasswordInput String
 4    | SendLogin
 5    | SendLoginResponse (Result Http.Error User)
 6
 7type Msg
 8    = UrlChange ...
 9    | UrlRequest ...
10    | LoginMessage LoginMessage
11    | ... 
12    other app messages

This seems like a good idea. The problem comes when you start to think of this as something that might be re-used. Now in fairness, a Login component may well satisfy that, but many other component seeming parts of your app are really only ever likely to work in that particular app. So let's try to write our update functions:

 1type alias Model =
 2    { loginForm : LoginForm
 3    , user : Maybe User
 4    , ....
 5    }
 6type alias LoginForm =
 7    { email : String
 8    , password : String
 9    , status : LoginStatus
10    }
11
12type LoginStatus
13    = Ready
14    | InFlight
15    | Error
16
17
18update : Msg -> Model -> ( Model, Cmd Msg)
19update message model =
20    case message of
21        UrlChange .. ->
22            ...
23        UrlRequest .. ->
24            ...
25        LoginMessage loginMessage ->
26            updateLogin loginMessage model
27
28updateLogin : LoginMessage -> Model -> ( Model, Cmd Msg)
29updateLogin loginMessage model =
30    case loginMessage of
31        EmailInput input ->
32            let
33                loginForm =
34                    model.loginForm
35            in
36            ( { model
37                | loginForm = { loginForm | email = input }
38              }
39            , Cmd.none
40            )
41        PasswordInput .. ->
42            .. similar
43        SendLogin ->
44            let
45                loginForm =
46                    model.loginForm
47            in
48            ( { model
49                | loginForm = { loginForm | status = InFlight }
50              }
51            , Requests.sendLogin loginForm
52            )
53        SendLoginResponse (Ok user) ->
54            let
55                loginForm =
56                    model.loginForm
57            in
58            ( { model
59                | loginForm = { loginForm | status = Error }
60              }
61            , Cmd.Msg
62            )
63
64        SendLoginResponse (Ok user) ->
65            ( { model
66                    | loginForm = emptyLoginForm
67                    , user = Just user
68                    }
69            , Cmd.none
70            )

Now this is all a touch, ..., ugly. There is a lot of the pattern of declaring the loginForm so that we can use record update syntax to change it. We could of course just define loginForm at the top of the function in a let declaration before the case. Still, the problem I see with this kind of 'breaking up of the update function' is that it is in name only. You basically have all the same cases that you would have had, it's just that at some point you have the start of a function declaration. However, the cases are all basically the same as they would have been had they been defined in the main update function. So technically you have split up the main function, but not really.

The smoking gun sign that you're guilty of this kind of faux factorising is the following pattern in your main update function:

1        LoginMessage loginMessage ->
2            updateLogin loginMessage model

Where the whole of your case is just to call the auxiliary function. That means the cases in your auxiliary function must be basically the same as they would have been in your main update function.

My main point here is that if you really want to 'break up your update function', then focus on the what changes in the model, and which cases require a command. That way you can usefully break up your update function. In this case, I see that there are two messages that require no commands and also only update the loginForm. So that is how I would choose to break up the main message variant.

Let's try again:

 1type LoginFormMessage
 2    = EmailInput String
 3    | PasswordInput String
 4
 5type Msg
 6    = UrlChange ...
 7    | UrlRequest ...
 8    | LoginMessage LoginMessage
 9    | SendLogin
10    | SendLoginResponse (Result Http.Error User)
11    | ... 
12    other app messages
13
14type alias Model =
15    { loginForm : LoginForm
16    , loginStatus : LoginStatus
17    , user : Maybe User
18    , ....
19    }
20type alias LoginForm =
21    { email : String
22    , password : String
23    }
24
25type LoginStatus
26    = Ready
27    | InFlight
28    | Error
29
30
31update : Msg -> Model -> ( Model, Cmd Msg)
32update message model =
33    case message of
34        UrlChange .. ->
35            ...
36        UrlRequest .. ->
37            ...
38        LoginMessage loginMessage ->
39            ( { model
40                | loginForm = updateLoginForm loginMessage model.loginForm
41              }
42            , Cmd.none
43            )
44
45        SendLogin ->
46            ( { model | loginStatus = InFlight }
47            , Requests.sendLogin loginForm
48            )
49        SendLoginResponse (Ok user) ->
50            ( { model | loginStatus = Error }
51            , Cmd.none
52            )
53
54        SendLoginResponse (Ok user) ->
55            ( { model
56                    | loginForm = emptyLoginForm
57                    , loginStatus = Ready
58                    , user = Just user
59                    }
60            , Cmd.none
61            )
62updateLoginForm : LoginMessage -> LoginForm -> LoginForm
63updateLoginForm message form =
64    case message of
65        EmailInput input ->
66            { form | email = input }
67        PasswordInput input ->
68            { form | password = input }

Extensible Record Syntax #

One thing you could do, to make both forms a bit nicer is to scrap the loginForm nested record type. Just have those on the Model directly. So we would have something like the following:

 1type LoginFormMessage
 2    = EmailInput String
 3    | PasswordInput String
 4
 5type Msg
 6    = UrlChange ...
 7    | UrlRequest ...
 8    | LoginMessage LoginMessage
 9    | SendLogin
10    | SendLoginResponse (Result Http.Error User)
11    | ... 
12    other app messages
13
14type alias Model =
15    { loginEmail : String
16    , loginPassword : String
17    , loginStatus : LoginStatus
18    , user : Maybe User
19    , ....
20    }
21type alias LoginForm =
22    { email : String
23    , password : String
24    }
25
26type LoginStatus
27    = Ready
28    | InFlight
29    | Error
30
31
32update : Msg -> Model -> ( Model, Cmd Msg)
33update message model =
34    .. as before except ..
35        LoginMessage loginMessage ->
36            ( updateLoginForm loginMessage model
37            , Cmd.none
38            )
39
40type alias LoginForm a =
41    { a
42        | loginEmail : String
43        , loginPassword : String
44        }
45
46updateLoginForm : LoginMessage -> LoginForm a -> LoginForm  a
47updateLoginForm message form =
48    case message of
49        EmailInput input ->
50            { form | email = input }
51        PasswordInput input ->
52            { form | password = input }

There are a couple of reasons against this. The first is relevant here and the second is not relevant for a login form but is a more general point. So firstly, in this updated extensible record syntax, I've glossed over the case in which the login is successful and we have to 'empty' the login form. In the original version we assumed there was some emptyLoginForm defined somewhere, and probably used in the init function, so we can use it to reset the nested record type. However, we cannot do that trick with an extensible record, so we're forced to do something like:

1        SendLoginResponse (Ok user) ->
2            ( { model
3                    | loginEmail = ""
4                    , loginPassword = ""
5                    , loginStatus = Ready
6                    , user = Just user
7                    }
8            , Cmd.none
9            )

That's fine, but what if we add something to the login form, suppose you need to take someone's 2FA code as well? Now you have to remember to come back here and reset that. With a nested record type you will be informed of all those places where you need to update/reset that newly added field.

The second reason doesn't apply for login forms, but I have been bitten with. It occurs when you have a single 'object' let's call it a 'form' that you use in your application, which then becomes multiple, perhaps a list of such 'objects'. So for example, maybe you have a comment form for your online store for people to comment on the store. But then you realise that you want a comment form on each individual product. To drive home the point let's torture our loginForm example and suppose that you might have an admin login as well. So now your app needs two login forms. With the extensible record form, you're a bit stuck writing the same code twice. But with a nested record type this is quite simple:

 1type alias Model =
 2    { loginForm : LoginForm
 3    , adminLogin : LoginForm
 4    , loginStatus : LoginStatus
 5    , user : Maybe User
 6    , ....
 7    }
 8type Msg
 9    = UrlChange ...
10    | UrlRequest ...
11    | LoginMessage LoginMessage
12    | AdminLoginMessage LoginMessage
13    | ... 
14    other app messages
15
16update : Msg -> Model -> ( Model, Cmd Msg)
17update message model =
18    .. as before except ..
19        LoginMessage loginMessage ->
20            ( { model | loginForm = updateLoginForm loginMessage model.loginForm }
21            , Cmd.none
22            )
23        AdminLoginMessage loginMessage ->
24            ( { model | adminLogin = updateLoginForm loginMessage model.adminLogin }
25            , Cmd.none
26            )

So the nice thing here is that I actually got to re-use my auxiliary function, that's generally at least one purpose of factoring out code into its own function, so that you can re-use it.

Admittedly you now have the problem that you have to duplicate the user and loginStatus fields, and you probably have to duplicate those messages. Of course it would all depend on the exact semantics of your two-login-modes app, which again was a bit of a tortured example. Anyway now you probably want to re-consider your abstractions, which brings me to my final point.

Be fluid #

When splitting up your update function, be fluid. Perhaps we need to consider storing the user and loginStatus within the login form, but then use extensible record to write an updateLoginForm function that still only operates on the EmailInput and PasswordInput messages. That keeps our nice factoring out, but allows us to factor out the other functionality of sending the login request and dealing with the response as well.

Be fluid is important here, because one of the biggest risks of 'breaking-up your update function' is that you begin to make your data types and abstractions more concrete. More concrete means you are less likely to adapt them to the absolute best as new requirements or information come in.