Hi, I’m new to Elm and am building a simple quiz UI (such as that of Buzzfeed) to teach myself the language and environment. I see my UI fitting into three distinct stages:
Initial (showing the “hello and welcome to the quiz” screen)
Quiz question (stores a running tally of options selected)
Complete (shows the winning option)
I’m trying to model this with Elm, but I keep running into a wall, due to the fact that only a subset of Msgs are applicable in each stage. In particular, the Start message only makes sense in the Initial and Complete stages, and the Select and Next messages only make sense in the Quiz stage. My current approach uses Maybe everywhere, even when it doesn’t make sense, because I can’t figure out how to model Msgs that are only applicable in a particular stage.
you will need to write a module for every page: Page.Initial, Page.Quiz and Page.Complete.
Each page contains its own Model/Msg/View/Update.
You then will need to wire the pages together. The go-to tool for this is elm-spa.
But because you mentioned state machines, I’d like to recommend using Orasund/elm-action instead. It’s a package I wrote to model Elm programs as state machines.
Here is how you would set up your Main module:
import Page.Initial as Initial
import Page.Quiz as Quiz
import Page.Complete as Complete
import Action
type Model =
Initial Initial.Model
| Quiz Quiz.Model
| Complete Complete.Model
type Msg =
| InitialMsg Initial.Msg
| QuizMsg Quiz.Msg
| Complete Complete.Msg
update : Msg -> Model -> (Model, Cmd Msg)
update mg ml =
case (mg,ml) of
(InitialMsg msg, Initial model) ->
Initial.update msg model
|> Action.config
|> Action.withTrasition Quiz.init Quiz QuizMsg
|> Action.withUpdate Initial InitialMsg
|> Action.apply
(QuizMsg msg, Quiz model) ->
Quiz.update msg model
|> Action.config
|> Action.withTransition Complete.init Complete CompleteMsg
|> Action.withUpdate Quiz QuizMsg
|> Action.apply
(CompleteMsg msg, Complete model) ->
|> Action.config
|> Action.withUpdate Complete CompleteMsg
|> Action.apply
_ -> --Edit: added default case
(ml,Cmd.none)
There is no way of enforcing that certain messages only occur while your model is in a certain state. You have two options:
You can ignore the wrong messages.
You can transition into an error state when you receive a wrong message.
You have to decide and of course, you can mix and match depending on the severity of the wrong state/message combination.
Splitting the app into pages/modules can make this more pleasant because you can filter out the wrong pairs first and then you only have to handle the interesting correct combinations. However, you still have to make the decision somewhere; note that the example in the other post does not compile because the case (InitialMsg msg, Quiz model) is not handled, for example.
I tend to set up a default no-op for any (msg, model) combination that is not matched, rather than have an error state. Like this:
update : Msg -> Model -> (Model, Cmd Msg)
update mg ml =
case (mg,ml) of
...
(_, _) -> (ml, Cmd.none)
The reason being, that due to the synchronous nature of messages, it is possible for multiple message to be in-flight at the same time. For example, suppose a user presses a button very quickly twice in succession. The first click might cause a state change, and the second one might then land in the wrong state. It is usually safe to just no-op anything your state machine does not match, and bear that pattern in mind when designing it.
As a rule of thumb, I avoid using Maybe and underscore in case statements. Sometimes there is no way you can avoid them but I prefer to change my model instead.
Consider remote data, as a typical example. Data that is loaded from the server is either fetched or not fetched yet, right? So our model could have a Maybe for everything that comes from the server. But then you realize that it could also be that the server responded with an error. Not to mention that there is a state where you are waiting for an answer. Fortunately, there is someone who has done something to this. Read How Elm Slays a UI Antipattern on how Elm slays an anti-pattern in UX.
Why not using catch-all in case statements? My main concern in this is that the compiler will not help you if you add a new case that you should handle. You sacrifice a bit of the type-driven development that is such joy when working with languages like Elm.
Yes, it is the disadvantage of this state machine pattern. But there could be a lot of (msg * state) combinations that are invalid, and it would be inconvenient to have to write them all out.
One solution to this is to split out messages into separate types of messages, without going quite as far as in Lucas_Payr’s suggestion (I generally try to avoid creating a module with a separate [model, view, update] triad unless absolutely necessary: both because of the overhead and because you often realise later that you didn’t want to compartmentalize the data as much as you originally thought!). So you could do this:
type Model
= Initial InitialState
| Quiz QuizState
| Complete CompleteState
type Msg
= InitialEvent InitialMsg
| QuizEvent QuizMsg
| CompleteEvent CompleteMsg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case ( msg, model ) of
( InitialEvent iMsg, Initial state ) ->
initialUpdate iMsg state
( QuizEvent qMsg, Quiz state ) ->
quizUpdate qMsg state
( CompleteEvent cMsg, Complete state ) ->
completeUpdate cMsg state
_ ->
( model, Cmd.none )
initialUpdate : InitialMsg -> InitialState -> ( Model, Cmd Msg )
initialUpdate iMgs state =
...
Each of of initialUpdate, quizUpdate and completeUpdate will still have a full case statement covering every possible relevant message. So now if you add an additional possible message to the QuizMessage type the compiler will still make sure you handle it.