Today we'll introduce RemoteData to the time-tracker. This will help push us into having a nicer user experience, as we always have to handle the case where we're waiting on data from the backend. Let's get started.

Project

I've introduced a slowdown in the API request for fetching users so that we are forced to see what our app looks like with a slow connection. Let's have a look at that.

((( check out the users index, flip pages )))

OK, so this is kind of awful. Our user has no indication that things are happening and the user experience is atrocious. This was an oversight, but since we have been developing against our localhost API it was never something we really had to think about it. We can fix it, but wouldn't it be nicer if it wasn't even possible for us to overlook this problem? Luckily, Kris Jenkins has our back. Let's install remotedata:

elm package install -y krisajenkins/remotedata

Now we'll update our model to indicate that the remote resources are in fact RemoteData.

vim src/Model.elm
module Model exposing (Model, UsersModel, ProjectsModel, OrganizationsModel, initialModel)
-- ...
import RemoteData exposing (RemoteData(..))
import OurHttp
-- ...
type alias UsersModel =
    -- We specify both the errors that we expect to get as well as the success type
    { users : RemoteData OurHttp.Error (Paginated User)
    -- ...
    }

type alias ProjectsModel =
    { projects : RemoteData OurHttp.Error (Paginated Project)
    -- ...
    }


type alias OrganizationsModel =
    { organizations : RemoteData OurHttp.Error (Paginated Organization)
    -- ...
    }

-- and update the initial model to handle the new type.  When we start out, we
-- have remote data we haven't asked for yet, so we'll use the NotAsked
-- constructor for RemoteData
initialModel : Maybe Route.Location -> Model
initialModel location =
    { -- ...
    , usersModel =
        { -- ...
        , users = NotAsked
        -- ...
        }
    , projectsModel =
        { projects = NotAsked
        -- ...
        }
    , organizationsModel =
        { organizations = NotAsked
        -- ...
        }
    }

Now when we fetch the data in our API, we want to map it to a RemoteData via Cmd.map, which is bottled up nicely in RemoteData.asCmd:

vim src/API.elm
module API
-- ...
import RemoteData
-- ...
-- We no longer need to map errors in the API calls themselves, so this will
-- require a bit of wiring to implement.  We start with the generic fetch
-- functions.  Note we also have to update the type for the 'msg' we get back
fetchResources : String -> JD.Decoder (List a) -> Model -> (RemoteData.RemoteData Error (Paginated a) -> Msg) -> Cmd Msg
fetchResources endpoint decoder model msg =
    fetchResourcesWithUrl endpoint decoder model msg
-- ...
fetchResourcesWithUrl : String -> JD.Decoder (List a) -> Model -> (RemoteData.RemoteData Error (Paginated a) -> Msg) -> Cmd Msg
fetchResourcesWithUrl url decoder model msg =
    let
        -- ...
    in
        getPaginated model apiPath decoder msg
-- ...
-- Now we'll remove the error types from our fetch functions for the various
-- resources.
fetchUsers : Model -> (RemoteData.RemoteData Error (Paginated User) -> Msg) -> Cmd Msg
fetchUsers model msg =
    fetchResources "/users" Decoders.usersDecoder model msg


fetchUsersWithUrl : String -> Model -> (RemoteData.RemoteData Error (Paginated User) -> Msg) -> Cmd Msg
fetchUsersWithUrl url model msg =
    fetchResourcesWithUrl url Decoders.usersDecoder model msg
-- ...
fetchProjects : Model -> (RemoteData.RemoteData Error (Paginated Project) -> Msg) -> Cmd Msg
fetchProjects model msg =
    fetchResources "/projects" Decoders.projectsDecoder model msg


fetchProjectsWithUrl : String -> Model -> (RemoteData.RemoteData Error (Paginated Project) -> Msg) -> Cmd Msg
fetchProjectsWithUrl url model msg =
    fetchResourcesWithUrl url Decoders.projectsDecoder model msg
-- ...
fetchOrganizations : Model -> (RemoteData.RemoteData Error (Paginated Organization) -> Msg) -> Cmd Msg
fetchOrganizations model msg =
    fetchResources "/organizations" Decoders.organizationsDecoder model msg


fetchOrganizationsWithUrl : String -> Model -> (RemoteData.RemoteData Error (Paginated Organization) -> Msg) -> Cmd Msg
fetchOrganizationsWithUrl url model msg =
    fetchResourcesWithUrl url Decoders.organizationsDecoder model msg
-- ...
-- We'll remove the error message from getPaginated, and also use
-- RemoteData.asCmd to handle mapping the task result into a RemoteData
getPaginated : Model -> String -> JD.Decoder (List a) -> (Paginated a -> Msg) -> Cmd Msg
getPaginated model path decoder msg =
    Http.send Http.defaultSettings
        (defaultRequest model path)
        |> OurHttp.fromJsonWithHeaders ("data" := decoder) paginationParser
        |> RemoteData.asCmd
        |> Cmd.map msg

Let's try to compile all of this and see what the compiler tells us we ought to do next. The bottom error tells us that we ought to move on to the Msg module and modify the types for GotUsers and friends to accept the RemoteData value rather than a Paginated value:

module Msg exposing (Msg(..), UserMsg(..), ProjectMsg(..), OrganizationMsg(..), LoginMsg(..))
-- ...
import RemoteData exposing (RemoteData)
-- ...
type UserMsg
    -- ...
    | GotUsers (RemoteData OurHttp.Error (Paginated User))
-- ...
type ProjectMsg
    -- ...
    | GotProjects (RemoteData OurHttp.Error (Paginated Project))
-- ...
type OrganizationMsg
    -- ...
    | GotOrganizations (RemoteData OurHttp.Error (Paginated Organization))

Alright, we can try to build it now and see what happens. Now we see some issues with the Update module: we need to update our calls when fetching the data:

module Update exposing (update)
-- ...
import RemoteData exposing (RemoteData(..))
-- ...
updateUserMsg : Model -> UserMsg -> UsersModel -> ( UsersModel, Cmd Msg, Maybe ( String, String ) )
updateUserMsg model msg usersModel =
    case msg of
        FetchUsers url ->
            ( { usersModel | users = Loading }, API.fetchUsersWithUrl url model (UserMsg' << GotUsers), Nothing )

        GotUsers users ->
            ( { usersModel | users = users }, Cmd.none, Nothing )
        -- ...
        DeleteUserSucceeded user ->
            ( { usersModel | users = Loading }
            , API.fetchUsers model (UserMsg' << GotUsers)
            , Nothing
            )
-- ...
updateProjectMsg : Model -> ProjectMsg -> ProjectsModel -> ( ProjectsModel, Cmd Msg, Maybe ( String, String ) )
updateProjectMsg model msg projectsModel =
    case msg of
        FetchProjects url ->
            ( { projectsModel | projects = Loading }, API.fetchProjectsWithUrl url model (ProjectMsg' << GotProjects), Nothing )

        GotProjects projects ->
            ( { projectsModel | projects = projects }
            , Cmd.none
            , Nothing
            )
        -- ...
        DeleteProjectSucceeded project ->
            ( { projectsModel | projects = Loading }
            , API.fetchProjects model (ProjectMsg' << GotProjects)
            , Nothing
            )
-- ...
updateOrganizationMsg : Model -> OrganizationMsg -> OrganizationsModel -> ( OrganizationsModel, Cmd Msg, Maybe ( String, String ) )
updateOrganizationMsg model msg organizationsModel =
    case msg of
        FetchOrganizations url ->
            ( { organizationsModel | organizations = Loading }, API.fetchOrganizationsWithUrl url model (OrganizationMsg' << GotOrganizations), Nothing )

        GotOrganizations organizations ->
            ( { organizationsModel | organizations = organizations }
            , Cmd.none
            , Nothing
            )
        -- ...
        DeleteOrganizationSucceeded organization ->
            ( { organizationsModel | organizations = Loading }
            , API.fetchOrganizations model (OrganizationMsg' << GotOrganizations)
            , Nothing
            )

If we try to build the project now, the compiler reminds us that we also used these API functions from the Util module, so let's fix those:

module Util exposing (cmdsForModelRoute, MaterialTableHeader)
-- ...
cmdsForModelRoute : Model -> List (Cmd Msg)
cmdsForModelRoute model =
    case model.route of
        -- We'll actually remove the 401 handling for the moment - we should
        -- really add this to the higher level API module for the most part
        -- anyway!
        Just Users ->
            [ API.fetchUsers model <| UserMsg' << GotUsers
            ]
        -- ...
        Just Projects ->
            [ API.fetchProjects model <| ProjectMsg' << GotProjects ]
        -- ...
        Just Organizations ->
            [ API.fetchOrganizations model <| OrganizationMsg' << GotOrganizations ]

When we build it now, we've finally made it to the views! Which is the whole point, really. Now we can get into the views and handle the cases that this type is requiring us to handle.

We'll start with the Users view:

module View.Users exposing (view, header)
-- ...
import RemoteData exposing (RemoteData(..))
-- ...
usersCards : Model -> Html Msg
usersCards model =
    case model.usersModel.users of
        NotAsked ->
            text "Initialising..."

        Loading ->
            text "Loading..."

        Failure err ->
            text <| "There was a problem fetching the users: " ++ toString err

        Success paginatedUsers ->
            grid [] <|
                List.map
                    (\user -> cell [ size All 3 ] [ userCard user ])
                    paginatedUsers.items
-- ...
usersTable : Model -> Html Msg
usersTable model =
    case model.usersModel.users of
        NotAsked ->
            text "Initialising..."

        Loading ->
            text "Loading..."

        Failure err ->
            text <| "There was a problem fetching the users: " ++ toString err

        Success paginatedUsers ->
            -- ...

This is a bit frustrating with respect to copy-paste, but we'll ignore it for now and move on to projects:

module View.Projects exposing (view, header)
-- ...
import RemoteData exposing (RemoteData(..))
-- ...
projectsTable : Model -> Html Msg
projectsTable model =
    case model.projectsModel.projects of
        NotAsked ->
            text "Initialising..."

        Loading ->
            text "Loading..."

        Failure err ->
            text <| "There was a problem fetching the projects: " ++ toString err

        Success paginatedProject ->
            -- ...

And organizations:

module View.Organizations exposing (view, header)
-- ...
import RemoteData exposing (RemoteData(..))
-- ...
organizationsTable : Model -> Html Msg
organizationsTable model =
    case model.organizationsModel.organizations of
        NotAsked ->
            text "Initialising..."

        Loading ->
            text "Loading..."

        Failure err ->
            text <| "There was a problem fetching the organizations: " ++ toString err

        Success paginatedOrganizations ->
            -- ...

WHEW

Alright, so this took a bit but that's not unexpected since we changed core types. A better situation of course would be to know these sorts of things ahead of time so that we can start out projects with them :) Still, now it's all set up so we should check it out and see where we stand.

((( click through, note that when paginating we get loading text )))

Summary

So I think that this is fantastic. We now have types that force us to handle some very important use cases that we might forget about otherwise. There are a lot more features that RemoteData offers to interact with this data structure as well, involving things like mapping the success value, chaining with andThen, ignoring errors using withDefault, and more. I hope you'll give it a shot. See you soon!

Resources

Josh Adams

I've been building web-based software for businesses for over 18 years. In the last four years I realized that functional programming was in fact amazing, and have been pretty eager since then to help people build software better.

You May Also Like