Refactoring an http Request Into a State Machine in Elm

This blog post details a talk I gave at a local front end meetup and is inspired by Richard Feldman’s talk on Making Impossible States Impossible and Kris Jenkin’s post How Elm Slays a UI Antipattern.

I gave a talk on elm at a local front end meetup. I had 45 minutes. Teach them elm? There’s plenty of youtube videos for that. I wanted to inspire them. Hoping they would try elm in their spare time. I had to make a compelling pitch. I had to show them how different it could be to write in elm. So I started thinking of what was different between what I write in react / redux from what I write in elm. And union types came to mind. But how? I needed something that most everyone in the crowd would be familiar with. Http requests. If you’ve spent any time in front end development chances are you’ve made plenty of them. And so we walked through refactoring an http request into a state machine. Below is how we did it.

The app I built is one that fetches repos from github based on a selectable list of programming languages (fp languages that compile to js) and a date input. This displays all the newly created repos for that language from the selected date forward. I created a Github repo to show the differences from one version to the next. The repo has three branches; originalRequest, maybeRefactor, and unionTypeRefactor so they could see the progression and differences.

This article breaks down each branch so you can see the changes to the model, update and view as you read through it.

code not relevant to the example is replaced with …


originalRequest

This is the first branch, and how I began writing code in elm coming from react / redux.

model and init

type alias Model =
{ isLoading : Bool
, resultsReceived : Bool
, repos : List Repo
, errorMessage : Maybe String
...
}
type alias Repo =
...
init : ( Model, Cmd Msg )
init =
( { isLoading = False
, resultsReceived = False
, repos = []
, errorMessage = Nothing
...
}
, setTime
)

Our model has 2 boolean flags (isLoading and resultsReceived), a list of repos (empty to start), and an error message (maybe string).

  • isLoading is set to True if we are making a request and False otherwise. This allows us to show a loading state during the request.
  • resultsReceived is set to True if we have requested some data and False if we haven’t (init model set to False). We have to remember to reset to False when the result is returned, and reset to True when a new request is made.
  • the error message displays errors from our http request

So why resultsReceived? Because it’s a necessity for good ux as we have to differentiate between an empty list returned from our request and our initial empty list in our model. This way we show nothing when our app starts up and a message saying our request returned no results if a request returns no repos.

our update function

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
...
FetchRepos ->
( { model
| repos = []
, isLoading = True
, resultsReceived = False
}
, fetchRepos model
)
RequestReceived result ->
case result of
Ok repos ->
( { model
| errorMessage = Nothing
, repos = repos
, isLoading = False
, resultsReceived = True
}
, Cmd.none
)
Err error ->
( { model
| errorMessage =
Just (httpErrorString error "Woops! ")
, isLoading = False
, resultsReceived = True
}
, Cmd.none
)

The booleans we’ve set up in our model need to be set and unset in our update function. These are easy to forget about, and the only way to be sure you haven’t forgotten to set them correctly is through testing, or user trial and error.

The errorMessage is set to a Maybe String, so we have help from the compiler when working with it in our update function. That’s nice, but should it be separate from the repos returned from our http request? (we’re getting ahead of ourselves)

There’s a lot to remember here and little help from the compiler. This means we’re going to need a lot of checks in our view function.

What do we need to track and display in our view? The following. Be prepared, this is a mess.

  • If our model is loading, then show a loading icon
  • if there are error messages, then show error messages
  • if the number of repos we have in our model equals 0, and we have received results from our request, then show a message stating the request returned no repos
  • if the number of repos we have in our model is greater than 0, and we have received results from our request, then show the repos returned from our request
  • If we have made no request, and have no repos, then show an empty div (this is the else block of each if statement)

And my mind is now swimming. These if statements are just nasty. Be warned, there’s some ugly code coming up.

our view function.

view : Model -> Html Msg
view model =
...
, if model.isLoading then
div [ class "row" ]
[ div [ class "col text-center" ]
[ Loading.loadingAnimation ]
]
else
div [] []
, if model.errorMessage /= Nothing then
div [ class "row" ]
[ div [ class "col" ]
[ p [ class "error-message" ]
[ text
(Maybe.withDefault
""
model.errorMessage) ] ]
]
else
div [] []
, if List.length model.repos == 0 && model.resultsReceived then
div [ class "row" ]
[ div [ class "col" ]
[ p [ class "message" ]
[ text "No repos found. Try searching from an earlier date, or get motivated and create a repo on github." ] ]
]
else
div [] []
, if List.length model.repos > 0 && model.resultsReceived then
div [ class "row" ]
[ reposTable model.repos ]
else
div [] []
]

Let’s recap…this is terrible!

These if statements are giving me a headache. They’re a result of the booleans we need to check for. Let’s start our refactor by getting rid of one of them.


maybeRefactor

We should start our refactor at the bottom of our view function. Those two if checks are there because we need to check if results from our http request have been received. Meaning, we need to differentiate between our initial empty list and one that returns no repos from a request. How can we better describe the empty list of repos? How about with a Maybe type? That just might work. (haha)

Instead of checking if our list is empty, or if we have made a request, we can set the initial state of repos in our model to Nothing. And Nothing represents repos much better at this point as we really don’t have anything. We haven’t made a request yet, so how can we have any data? We established that an empty list back from an http request is data, it’s just data that’s telling us we got zero repos back. That is different than not making a request at all and having nothing. So let’s do that. let’s set repos to be a Maybe (List Repo) and see how that changes our model, update and view.

model and init

type alias Model =
{ isLoading : Bool
, repos : Maybe (List Repo)
, errorMessage : Maybe String
...
}
type alias Repo =
...
init : ( Model, Cmd Msg )
init =
( { isLoading = False
, repos = Nothing
, errorMessage = Nothing
...
}
, setTime
)

One less boolean value to track. (no more requestReceived flag) That’s nice. It’ll make our update function easier to deal with. Also, our initial value for repos is now Nothing, which is more accurate than an empty list.

our update function.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
...
FetchRepos ->
( { model
| repos = Nothing
, isLoading = True
}
, fetchRepos model
)
RequestReceived result ->
case result of
Ok repos ->
( { model
| errorMessage = Nothing
, repos = Just repos
, isLoading = False
}
, Cmd.none
)
Err error ->
( { model
| errorMessage =
Just (httpErrorString error "Woops! ")
, isLoading = False
}
, Cmd.none
)

Better, but still not great. At least we’re not tracking if we’ve made a request, which eliminated three boolean flags we had to set in our update function. Also we are now more descriptive with setting the value of repos to either Nothing or Just repos. (we’ll handle the Just portion of our repos in our view function)

our view function

view model =
...
, if model.isLoading then
div [ class "row" ]
[ div [ class "col text-center" ]
[ Loading.loadingAnimation ]
]
else
div [] []
, if model.errorMessage /= Nothing then
div [ class "row" ]
[ div [ class "col" ]
[ p [ class "error-message" ]
[ text
(Maybe.withDefault
""
model.errorMessage) ] ]
]
else
div [] []
, div [ class "row" ]
[ reposTable model.repos ]
]
reposTable : Maybe (List Repo) -> Html Msg
reposTable repos =
case repos of
Nothing ->
div [] []
Just reposReceived ->
case reposReceived of
[] ->
div [ class "col" ]
[ p [ class "message" ]
[ text "No repos found. Try searching from an earlier date, or get motivated and create a repo on github." ] ]
_ ->
reposReceived
|> List.map reposTableBody
|> tbody []
|> appendTableHeader reposTableHeader
|> table [ class "table" ]

That’s better. Now we only have to check if we are loading, or if there is an error message. The function repostTable (which takes the place of the last two if statements from our original version) can pattern match off of model.repos (Nothing or Just repos) to display the correct view. This is nice as we can leave it to the elm compiler to tell us if we have forgotten to take Nothing into account, and no longer have to rely on our brains (yikes) to check for the correct state.

But how do we know if the request returned an empty list? We further pattern match off the list of repos in the Just repos case. (empty list [] or anything else _, knowing that if it’s not an empty list, it can only be a list with at least one repo) We then display a message saying “no repos found…”, or we display the repos in our table.

Let’s recap. Better, but still not great. We’re now leveraging the elm compiler, which is a good thing, and our logic checks aren’t so daunting. But we’re still checking for loading and error messages. How can we get rid of these and still correctly display all our possible states?


unionTypeRefactor

This is it. We need to readjust our thinking. The http request does not rule us, we rule it, and in doing so we need to describe it so that our application can be written in a way that only allows us to display the states we declare it has. Enter…union types.

Our repos model is currently described as a Maybe (List Repo) but it’s more than that. It’s really an http request waiting to hold the data we get back from it. So how would we describe that? Well, we’ve established that we need to differentiate between a requested and not requested state, so let’s start there and step through an http request.

  • Our first value for repos should be NotRequested, as we have established this as the initial state of our application and holds specific meaning, that being no data.
  • The second value for repos is the next boolean flag to eliminate: the loading state, which displays the moment we make a request. So our second value for repos is Loading.
  • Next we’ve got something a bit tricky. We’re either going to have an error, or some data. Each will have a value of type String (for the error) or list of repos (for a successful request). How about we make this simple. We’ll have two more values. One for Failure error and one for Success data. Great, we now have our 4 values for repo. They are:
  1. NotRequested
  2. Loading
  3. Failure error
  4. Success data

No other values will be possible for model.repos and the elm compiler will help us enforce that.

Now, if only elm gave us the ability to set a type and give it values. Something similar to the Bool type that has True and False as its values. Union Types to the rescue! Let’s name our type ExternalRequest capable of taking error and data and see how that changes our app.

our model. Pay particular attention to the type ExternalResource error data, and the values it has.

type alias Model =
{ repos : ExternalResource Http.Error (List Repo)
...
}
type ExternalResource error data
= NotRequested
| Loading
| Failure error
| Success data
type alias Repo =
...
init : ( Model, Cmd Msg )
init =
( { repos = NotRequested
...
}
, setTime
)

Wow! We’ve eliminated isLoading and errorMessage from our model. Nice. And we’ve got a new union type called ExternalResource with the values we’ve declared for it. We can now set repos to have a value of ExternalResource Http.error (List Repo), which is a type we created and have set the values for. These can now be applied to our repos model in our init and update function.

Our init is now much smaller and sets repos to the value of NotRequested.

We can follow the same logic in our update function. All we have to do is set repos to the correct value for each message and move along. If we’re fetching repos, then repos is equal to Loading, when we receive our results, we set the Ok pattern match of result to Success repos, and the Err case to Failure error. This is far easier than having to remember to set and unset flags all over the place.

our update function

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
...
FetchRepos ->
( { model
| repos = Loading
}
, fetchRepos model
)
RequestReceived result ->
case result of
Ok repos ->
( { model
| repos = Success repos
}
, Cmd.none
)
Err error ->
( { model
| repos = Failure error
}
, Cmd.none
)

Much easier to read. It’s also clear what the value of repos is throughout our update function.

And for the grand finale, our view function

view : Model -> Html Msg
view model =
...
, div [ class "row" ]
[ case model.repos of
NotRequested ->
div [] []
Loading ->
div [ class "col text-center" ]
[ Loading.loadingAnimation ]
Failure error ->
let
errorMessage =
Just (httpErrorString error "Woops! ")
in
div [ class "col" ]
[ p [ class "error-message" ]
[ text
(Maybe.withDefault
""
errorMessage) ] ]
Success repos ->
reposTable repos
]
]

A simple pattern match off the values in model.repos and we display the appropriate view. No more boolean checks. And as we’re pattern matching off of a union type, the elm compiler will remind us to account for each value in our case statement. How does it do this? Because we set repos to have a type of ExternalResource with four specific values. The compiler knows the values we gave it, so it’ll yell at us (a nice yell) if we forget to account for any of them in our case statement.

And there we have it. A state machine. One that describes our http request, accounts for the possible states and displays them to the screen.

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.