6.5

RemoteData

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 Not Asked state because we haven’t asked for data yet. When the button is clicked the request transitions to Loading state. If the request is successful, it ends up in Success state. If not, it moves to 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 place. 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-package install krisajenkins/remotedata

Answer y when asked to add the package as a dependency to elm-package.json and approve the installation plan. After that, import the RemoteData module in DecodingJson.elm.

module DecodingJson exposing (..)
.
.
import RemoteData exposing (WebData)

Handling Not Asked State

RemoteData provides a type called WebData which is defined like this:

type alias WebData a =
      RemoteData Http.Error a

It’s just an alias for another concrete type called RemoteData which has the same name as the module.

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 too such as Array, Html, and Task.

Here is what the RemoteData type’s definition looks like:

type RemoteData error value
    = NotAsked
    | Loading
    | Failure error
    | Success value

Aha! The four data constructors above look identical to the various states we saw earlier. If we were fetching data from a non-HTTP server such as FTP, the request would still go through the same four states. Therefore, the RemoteData type can be used with non-HTTP requests as well.

Elm apps mostly retrieve data from an HTTP (also known as Web) server. That’s why the RemoteData module has hardcoded the error type to Http.Error in WebData’s definition. WebData exists specifically to represent data retrieved by an HTTP request, so we’ll be using it instead of the RemoteData type throughout this section.

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 WebData. Go ahead and change the Model type in DecodingJson.elm to this:

type alias Model =
    { posts : WebData (List Post)
    }

The errorMessage field was also removed. We don’t need to store an error message in our model anymore. More on this later.

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 actually 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 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. Update init in DecodingJson.elm like this:

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: Use sendRequest to Create a Command

Right now we’re using the Http.send function to generate a command.

httpCommand : Cmd Msg
httpCommand =
    list postDecoder
        |> Http.get "http://localhost:5019/posts"
        |> Http.send DataReceived

RemoteData requires us to use a different function called sendRequest. In addition to creating an HTTP command, the sendRequest function also wraps the response in WebData type. Let’s use it in httpCommand.

httpCommand : Cmd Msg
httpCommand =
    list postDecoder
        |> Http.get "http://localhost:5019/posts"
        |> RemoteData.sendRequest
        |> Cmd.map DataReceived

Notice how we moved DataReceived to the next line and passed it as the first argument to Cmd.map. Unlike Http.send, the sendRequest function doesn’t accept a message as one of its arguments.

sendRequest :                            Request a -> Cmd (WebData a)
send        : (Result Error a -> msg) -> Request a -> Cmd msg

If it doesn’t take a message then how will the update function be notified after the command is executed? The answer to that question lies in sendRequest’s return type: Cmd (WebData a). It hardcodes the message type as WebData a.

Wait a minute. Can sendRequest use a type alias (WebData) as a message? It totally can. The term message by itself doesn’t have any significance in Elm. Any data type can play the role of message. We’ve been calling the data constructors in Msg type messages because it’s easier to understand the interaction between the Elm Runtime and our application if we think of them as two entities communicating with each other by sending messages.

Step 5: Modify the update Function

When a command generated by sendRequest is executed, the update function is sent a message of type WebData a. Usually, we would handle that message like this:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SendHttpRequest ->
            ( model, httpCommand )

        RemoteData.NotAsked ->
            ...

        RemoteData.Loading ->
            ...

        RemoteData.Success posts ->
            ...

        RemoteData.Failure error ->
            ...

But we can’t really do that. update takes a message of type Msg, whereas the message sent to it is of type WebData a. To resolve this, we can map WebData a to Msg by using the Cmd.map function.

httpCommand : Cmd Msg
httpCommand =
    list postDecoder
        |> Http.get "http://localhost:5019/posts"
        |> RemoteData.sendRequest
        |> Cmd.map DataReceived

Here is what Cmd.map’s type signature looks like:

Cmd.map : (a -> msg) -> Cmd a -> Cmd msg

Now we can simply assign the payload in DataReceived message, which is of type WebData a, to the posts field in our model. Modify the update function to this:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SendHttpRequest ->
            ( model, httpCommand )

        DataReceived response ->
            ( { model | posts = response }, Cmd.none )

Previously, we had two branches for handling the DataReceived message:

DataReceived (Ok posts) ->
    ( { model | posts = posts }, Cmd.none )

DataReceived (Err httpError) ->
    ( { model
        | errorMessage = Just (createErrorMessage httpError)
      }
    , Cmd.none
    )

The first branch was for extracting a list of posts from a Result type before assigning them to our model. The second branch was for extracting an error. With WebData all of this extraction will happen in view code.

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 (createErrorMessage 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. 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/elm-examples/DecodingJson.elm. The only element you should see is a button.

Transitioning to the Loading State

You’ll see a table containing posts immediately after clicking the Get data from server button. 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 2000

--delay takes the number of milliseconds as an argument. json-server will now wait for two seconds before responding to all requests. Refresh the page at http://localhost:8000/elm-examples/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 before firing off the HTTP command to the Elm Runtime. 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/elm-examples/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 =
    list postDecoder
        |> Http.get "http://localhost:5019/invalid"
        |> RemoteData.sendRequest
        |> Cmd.map DataReceived

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/elm-examples/HttpExamples.elm and click the Get data from server button. You should see the following error message after the loading text has disappeared.

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 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 (..)

import Html exposing (..)
import Html.Attributes exposing (href)
import Html.Events exposing (onClick)
import Http
import Json.Decode exposing (string, int, list, Decoder)
import Json.Decode.Pipeline exposing (decode, required)
import RemoteData exposing (WebData)


type alias Author =
    { name : String
    , url : String
    }


type alias Post =
    { id : Int
    , title : String
    , author : Author
    }


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 (createErrorMessage 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 (toString post.id) ]
        , td []
            [ text post.title ]
        , td []
            [ a [ href post.author.url ] [ text post.author.name ] ]
        ]


type Msg
    = SendHttpRequest
    | DataReceived (WebData (List Post))


authorDecoder : Decoder Author
authorDecoder =
    decode Author
        |> required "name" string
        |> required "url" string


postDecoder : Decoder Post
postDecoder =
    decode Post
        |> required "id" int
        |> required "title" string
        |> required "author" authorDecoder


httpCommand : Cmd Msg
httpCommand =
    list postDecoder
        |> Http.get "http://localhost:5019/posts"
        |> RemoteData.sendRequest
        |> Cmd.map 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 )


createErrorMessage : Http.Error -> String
createErrorMessage httpError =
    case httpError of
        Http.BadUrl message ->
            message

        Http.Timeout ->
            "Server is taking too long to respond. Please try again later."

        Http.NetworkError ->
            "It appears you don't have an Internet connection right now."

        Http.BadStatus response ->
            response.status.message

        Http.BadPayload message response ->
            message


init : ( Model, Cmd Msg )
init =
    ( { posts = RemoteData.NotAsked }, Cmd.none )


main : Program Never Model Msg
main =
    program
        { init = init
        , view = view
        , update = update
        , subscriptions = \_ -> Sub.none
        }
Back to top

New chapters are coming soon!

Sign up for the Elm Programming newsletter to get notified!

* indicates required
Close