In the previous section, we created an app whose view looked like this before clicking the Get data from server button:
It’s not considered a good UI practice to show the Posts heading and table headers when we haven’t even fetched any data yet. We need to hide those until the data is retrieved. Before we do that though let’s look at different states an HTTP request tends to be in.
Before the Get data from server button is clicked the request for fetching posts is in the Not Asked
state because we haven’t asked for that data yet. When the button is clicked the request transitions to the Loading
state. If the request is successful, it ends up in the Success
state. If not, it moves to the Failure
state.
So far we have only dealt with the Success
and Failure
states. Once we handle the remaining two, our UI will be in a much better shape. We will be using a third-party package called krisajenkins/remotedata
to handle the remaining states. Go ahead and install it by running the following command from the beginning-elm
directory in terminal.
$ elm install krisajenkins/remotedata
Answer y
when asked to update elm.json
. After that, import the RemoteData
module in DecodingJson.elm
.
module DecodingJson exposing (main)
import RemoteData exposing (RemoteData, WebData)
.
.
Handling Not Asked State
The RemoteData
module provides a type by the same name.
type RemoteData error value
= NotAsked
| Loading
| Failure error
| Success value
Note: It’s perfectly fine to use the same name for a module and a type. In fact you will see this pattern over and over again with many built-in modules such as Array, Html, and Task.
Aha! The four data constructors look identical to different states we saw earlier. We need to modify several parts of our app to be able to take advantage of RemoteData
. What follows is a step-by-step guide for making those changes.
Step 1: Modify the Model
The first thing we need to do is change our model. Currently, we are directly assigning a list to the posts
field.
type alias Model =
{ posts : List Post
, errorMessage : Maybe String
}
We need to wrap that list with RemoteData
. Go ahead and change the Model
type in DecodingJson.elm
to this:
type alias Model =
{ posts : RemoteData Http.Error (List Post)
}
We removed the errorMessage
field because any potential error now resides in the posts
field itself. We can simplify the type of posts
by using WebData
instead.
type alias Model =
{ posts : WebData (List Post)
}
WebData
is defined in the RemoteData
module as a type alias.
type alias WebData a =
RemoteData Http.Error a
WebData
represents data fetched from an HTTP (also known as Web) server like ours. That’s why the error type is hard-coded to Http.Error
. If we were retrieving data from a non-HTTP server such as FTP, we would have to use the RemoteData
type instead of WebData
. All non-HTTP requests also go through the same four states we covered earlier.
Step 2: Modify init
Currently, we initialize the posts
field to an empty list.
init : () -> ( Model, Cmd Msg )
init _ =
( { posts = []
, errorMessage = Nothing
}
, Cmd.none
)
It doesn’t make sense to assign an empty list to posts
from the get go. What if the server responds with an empty list indicating there are genuinely no posts in the database? How do we differentiate between that scenario and not having requested data in the first place? The answer is to use NotAsked
instead of an empty list. Let’s replace []
with RemoteData.NotAsked
and remove errorMessage
from init
.
init : () -> ( Model, Cmd Msg )
init _ =
( { posts = RemoteData.NotAsked }, Cmd.none )
Step 3: Modify DataReceived’s Payload Type
Next we need to replace the Result
type in DataReceived
message’s payload with WebData
.
type Msg
= SendHttpRequest
| DataReceived (WebData (List Post))
Step 4: Convert Result to RemoteData Value
Here is how we’re creating a command for fetching posts right now:
httpCommand : Cmd Msg
httpCommand =
Http.get
{ url = "http://localhost:5019/posts"
, expect = Http.expectJson DataReceived (list postDecoder)
}
The type of the expect
field in Http.get
depends on the type of our DataReceived
message.
get :
{ url : String
, expect : Expect msg
}
-> Cmd msg
Before we introduced RemoteData
, DataReceived
had the following type.
DataReceived : Result Http.Error (List Post) -> Msg
Now DataReceived
’s type has changed to this:
DataReceived : WebData (List Post) -> Msg
Since WebData
is just a type alias for RemoteData
, the above type signature is equivalent to this:
DataReceived : RemoteData Http.Error (List Post) -> Msg
This means the Result
type must be converted to RemoteData
. The RemoteData.fromResult
function is just what we need. Here is how its type signature looks:
fromResult : Result error value -> RemoteData error value
Update httpCommand
in DecodingJson.elm
to this:
httpCommand : Cmd Msg
httpCommand =
Http.get
{ url = "http://localhost:5019/posts"
, expect =
list postDecoder
|> Http.expectJson (RemoteData.fromResult >> DataReceived)
}
As mentioned in the Using » Operator section in chapter 5, >>
is a built-in operator for composing multiple functions. Here is an example:
func1 >> func2 == \param -> func2 (fun1 param)
So conceptually we can think of RemoteData.fromResult >> DataReceived
as:
result = Result Http.Error (List Post)
\result -> DataReceived (RemoteData.fromResult result)
Remember, all messages that contain a payload are essentially functions behind the scenes. That’s why we were able to use >>
with DataReceived
.
Step 5: Modify update
DataReceived
’s payload is now of type WebData
instead of Result
. That means we can replace the DataReceived (Ok posts) ->
and DataReceived (Err httpError) ->
branches in update
with something much simpler.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SendHttpRequest ->
( model, httpCommand )
DataReceived response ->
( { model | posts = response }, Cmd.none )
All we’re doing in the DataReceived response ->
branch is assign whatever response
we get to the posts
field in our model.
Step 6: Modify View Code
The only remaining change is to handle different states in our view code. Modify the viewPostsOrError
function in DecodingJson.elm
to this:
viewPostsOrError : Model -> Html Msg
viewPostsOrError model =
case model.posts of
RemoteData.NotAsked ->
text ""
RemoteData.Loading ->
h3 [] [ text "Loading..." ]
RemoteData.Success posts ->
viewPosts posts
RemoteData.Failure httpError ->
viewError (buildErrorMessage httpError)
Previously, we were checking for the presence of an error message to determine whether to display posts or an error view.
viewPostsOrError : Model -> Html Msg
viewPostsOrError model =
case model.errorMessage of
Just message ->
viewError message
Nothing ->
viewPosts model.posts
Now we’re determining which view to display based on different states defined in the RemoteData
type. We’re ready to test our app. Run elm reactor
from the beginning-elm
directory in terminal if it’s not running already and go to this URL in your browser: http://localhost:8000/src/DecodingJson.elm
. The only element you should see is a button.
Transitioning to the Loading State
Run json-server
from the beginning-elm
directory in terminal using the following command if it’s not running already.
$ json-server --watch server/db.json -p 5019
Click the Get data from server button and you should immediately see a table containing posts. Although the HTTP request transitions to Loading state after the button is clicked, we don’t see Loading...
on the page. For that text to appear, we need to make our server wait a couple of seconds before returning a response. Stop json-server
by pressing Ctrl + c
and restart it with the --delay
option.
$ json-server --watch server/db.json -p 5019 --delay 1000
--delay
takes the number of milliseconds as an argument. json-server
will now wait for a second before responding to all requests. Refresh the page at http://localhost:8000/src/DecodingJson.elm
and click the Get data from server button.
Hmm… We’re still not seeing Loading...
. We forgot to change the state from NotAsked
to Loading
in update
before firing off the HTTP command. RemoteData
doesn’t transition to Loading automatically. We need to do that manually inside the SendHttpRequest ->
branch in update
.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SendHttpRequest ->
( { model | posts = RemoteData.Loading }, httpCommand )
.
.
Refresh the page at http://localhost:8000/src/DecodingJson.elm
one more time and click the Get data from server button. You should now see Loading...
while the posts are being fetched.
Transitioning to the Failure State
Change the URL inside httpCommand
in DecodingJson.elm
to something invalid.
httpCommand : Cmd Msg
httpCommand =
Http.get
{ url = "http://localhost:5019/invalid"
.
.
Since we’re requesting a non-existent resource, the HTTP request will eventually transition to the Failure
state. Refresh the page at http://localhost:8000/src/DecodingJson.elm
and click the Get data from server button. You should see the following error message.
Don’t forget to change the URL back to http://localhost:5019/posts
in httpCommand
.
Summary
In this section, we used a third-party package called krisajenkins/remotedata
to improve our UI by properly handling all four states an HTTP request can be in at any given time. Those four states are: NotAsked
, Loading
, Success
, and Failure
. Kris Jenkins — the author of that package — has written a wonderful blog post explaining the rationale behind creating RemoteData
. I highly recommend you read it.
In the next section, we will learn how to send an HTTP command when the app is being initialized. Here is the entire code from DecodingJson.elm
thus far:
module DecodingJson exposing (main)
import Browser
import Html exposing (..)
import Html.Attributes exposing (href)
import Html.Events exposing (onClick)
import Http
import Json.Decode as Decode exposing (Decoder, int, list, string)
import Json.Decode.Pipeline exposing (required)
import RemoteData exposing (RemoteData, WebData)
type alias Post =
{ id : Int
, title : String
, authorName : String
, authorUrl : String
}
type alias Model =
{ posts : WebData (List Post)
}
view : Model -> Html Msg
view model =
div []
[ button [ onClick SendHttpRequest ]
[ text "Get data from server" ]
, viewPostsOrError model
]
viewPostsOrError : Model -> Html Msg
viewPostsOrError model =
case model.posts of
RemoteData.NotAsked ->
text ""
RemoteData.Loading ->
h3 [] [ text "Loading..." ]
RemoteData.Success posts ->
viewPosts posts
RemoteData.Failure httpError ->
viewError (buildErrorMessage httpError)
viewError : String -> Html Msg
viewError errorMessage =
let
errorHeading =
"Couldn't fetch data at this time."
in
div []
[ h3 [] [ text errorHeading ]
, text ("Error: " ++ errorMessage)
]
viewPosts : List Post -> Html Msg
viewPosts posts =
div []
[ h3 [] [ text "Posts" ]
, table []
([ viewTableHeader ] ++ List.map viewPost posts)
]
viewTableHeader : Html Msg
viewTableHeader =
tr []
[ th []
[ text "ID" ]
, th []
[ text "Title" ]
, th []
[ text "Author" ]
]
viewPost : Post -> Html Msg
viewPost post =
tr []
[ td []
[ text (String.fromInt post.id) ]
, td []
[ text post.title ]
, td []
[ a [ href post.authorUrl ] [ text post.authorName ] ]
]
type Msg
= SendHttpRequest
| DataReceived (WebData (List Post))
postDecoder : Decoder Post
postDecoder =
Decode.succeed Post
|> required "id" int
|> required "title" string
|> required "authorName" string
|> required "authorUrl" string
httpCommand : Cmd Msg
httpCommand =
Http.get
{ url = "http://localhost:5019/posts"
, expect =
list postDecoder
|> Http.expectJson (RemoteData.fromResult >> DataReceived)
}
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SendHttpRequest ->
( { model | posts = RemoteData.Loading }, httpCommand )
DataReceived response ->
( { model | posts = response }, Cmd.none )
buildErrorMessage : Http.Error -> String
buildErrorMessage httpError =
case httpError of
Http.BadUrl message ->
message
Http.Timeout ->
"Server is taking too long to respond. Please try again later."
Http.NetworkError ->
"Unable to reach server."
Http.BadStatus statusCode ->
"Request failed with status code: " ++ String.fromInt statusCode
Http.BadBody message ->
message
init : () -> ( Model, Cmd Msg )
init _ =
( { posts = RemoteData.NotAsked }, Cmd.none )
main : Program () Model Msg
main =
Browser.element
{ init = init
, view = view
, update = update
, subscriptions = \_ -> Sub.none
}