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 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
        }
Back to top

New chapters are coming soon!

Sign up for the Elm Programming newsletter to get notified!

* indicates required
Close