"Route reuse" + looking for general advice

Hello elm coders,

I’ve been digging my teeth into the Angular framework lately and would like to explore the Elm way of doing things for the next few days.

The functionality I’d like to reproduce can be seen here, in this stackblitz.

This demo represents the main features I’m looking for in a front end technology:

  • when hitting the root route, you are redirected to /form
  • visiting the page simulates triggering a long and expensive HTTP call without blocking the UI
  • when filling the form inputs, their values are reflected in the URL
    • this allows one to share links and recover the form state from the URL later
  • when clicking on “GO TO LIST” you go to another page
    • the previous form page stays in memory for later restoration
  • when clicking on “GO TO FORM”:
    • the form state is optionally restored if “Keep form data” was checked
    • the expensive HTTP is not triggered anymore since we already have the data
    • the whole page is cached, not the HTTP call.

The reason I want to cache the whole page is that restoring the UI from cached HTTP calls does not make for a good user experience IMO (at least in Angular). The problem being that there is a noticeable lag and jitter (maybe between 10 ms to 50ms) when rebuilding a complex page. Instead, I prefer to cache the whole page for instant feedback upon revisiting a page, while at the same time refreshing the data from new HTTP calls. That way, the user interface feels very snappy and almost desktop app like.

In Angular, I can obtain this behavior by making use of a tricky technique: implementing a custom “route reuse strategy”. With this technique, a previously built page object can be restored from a global cache. But using such a technique can open one up to all sort of bugs and hard to manage life cycle state (that can lead to unintended memory leaks, etc.).

So that’s why I’d like to explore Elm again. I went through 75% of the guide a few months ago and I’m looking for practical advice and what to learn to be able to achieve this demo. And maybe if I should use any libraries such as this one: elm-router 2.0.0

Thanks for any feedback

As far as I can understand, it looks like you want an SPA application where Elm has control over the whole UI.

About reusing the data and every thing, I thing it is as simple as storing the models of both view in separate entries, and just display the appropriate view based on the current route.

I’m on my phone, so it’s hard to type some code and may introduce some typos, but I see something like

type alias Model =
   { formModel : FormModel
   ,  listModel : ListModel
   ,  currentRoute : Route
  }

type Route
    = FormRoute
    | ListRoute

Use the Browser.Navigation lib to control the Url, and in your view, just use the appropriate view based on the value of currentRoute.

Thanks for the feedback @NicolasGuilloux

So yes indeed, I’m looking for a SPA. I now understand I need to use Browser.application for my needs.

I went through the guides once more as I had to refresh my memory on how one works with Elm.

Here is what I’ve got so far:

(ellie-app doesn’t seem to handle Browser.application unfortunately so no demo)

module Main exposing (main)

import Browser exposing (Document, UrlRequest(..))
import Browser.Navigation as Nav
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onInput)
import Url exposing (Url)
import Url.Parser as UP



-- ROUTING


type Route
    = FormPage
    | ListPage


routeParser : UP.Parser (Route -> a) a
routeParser =
    UP.oneOf
        [ UP.map FormPage (UP.s "form")
        , UP.map ListPage (UP.s "list")
        ]



-- MAIN


main : Program () Model Msg
main =
    Browser.application
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        , onUrlRequest = ClickLink
        , onUrlChange = ChangeUrl
        }



-- MODEL


type alias FormModel =
    { firstName : String
    , lastName : String
    }


type alias Model =
    { key : Nav.Key
    , route : Maybe Route
    , formModel : FormModel
    }


init : () -> Url -> Nav.Key -> ( Model, Cmd Msg )
init _ url key =
    ( { key = key, route = UP.parse routeParser url, formModel = FormModel "" "" }, Cmd.none )



-- UPDATE


type Msg
    = ChangeUrl Url
    | ClickLink UrlRequest
    | FirstNameChanged String
    | LastNameChanged String


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ChangeUrl url ->
            ( { model | route = UP.parse routeParser url }, Cmd.none )

        ClickLink urlRequest ->
            case urlRequest of
                Internal url ->
                    ( model, Nav.pushUrl model.key (Url.toString url) )

                External url ->
                    ( model, Nav.load url )

        FirstNameChanged val ->
            ( { model | formModel = FormModel val model.formModel.lastName }, Cmd.none )

        LastNameChanged val ->
            ( { model | formModel = FormModel model.formModel.firstName val }, Cmd.none )



-- VIEW


view : Model -> Document Msg
view model =
    let
        navBar =
            let
                padding =
                    style "margin" "20px"
            in
            div []
                [ a [ padding, href "/form" ] [ text "Form" ]
                , a [ padding, href "/list" ] [ text "List" ]
                ]

        content =
            case model.route of
                Just a ->
                    case a of
                        FormPage ->
                            viewForm model.formModel

                        ListPage ->
                            viewList

                Nothing ->
                    p [] [ text "Oops" ]
    in
    { title = "URL handling example"
    , body =
        [ navBar
        , content
        ]
    }


viewForm : FormModel -> Html Msg
viewForm fm =
    div []
        [ h1 [] [ text "A form" ]
        , Html.form []
            [ input [ type_ "text", value fm.firstName, onInput FirstNameChanged ] []
            , input [ type_ "text", value fm.lastName, onInput LastNameChanged ] []
            ]
        ]


viewList : Html msg
viewList =
    div []
        [ h1 [] [ text "A list" ]
        , div []
            [ ul []
                [ li [] [ text "A" ]
                , li [] [ text "B" ]
                , li [] [ text "C" ]
                ]
            ]
        ]

I’ve got the form values sticking in memory alright.

I’m now looking at syncing the URL with the form state. Any idea on how to go about this in Elm?

I also would like to “simulate” an HTTP request. Is this possible in Elm?

In Angular, one can do this:

// All those functions come from the RXJS library.
// This lib is integrated into Angular.
// `slowRequest$` is an "Observable"
// `tap` is for side effects
slowRequest$ = of('A SERVER MESSAGE').pipe(
    delay(5000),
    tap(_ => this.showSpinner = false),
  );

Then in my template I would subscribe to this observable via an “async pipe”.
Basically the framework handles the resource cleanup under the hood (subscribing/unsubscribing).

    <h4>Slow request:
      {{ slowRequest$ | async }}
      <app-spinner *ngIf="showSpinner"></app-spinner>
    </h4>

Once I’m ready to work on the backend code, I can then swap slowRequest$ with a real HTTP observable.

It’s a very handy feature, it’d be great if Elm had a similar workflow.

I know I’ve seen packages or such for mocking http in Elm, but my searching is coming up short. Another option is to use something like https://miragejs.com/ for mocking endpoints. You can simulate sending/receiving values from the back end, timeouts, whatever you want.

I’ve found the onUrlRequest / onUrlChange bifurcation in Browser.application to be really tedious to work with. It’s better than it was in 0.18, but still doesn’t feel natural to me. See more discussion in this thread:

Since that thread, I’ve completed updating elm-route-url to 0.19 (but it hasn’t been merged yet).

I’ve also built upon that to create a feature I call “anchor management”. I’ve been using it in my app for a while now and really like it.

The basic idea is that your view function gets a msg -> ... -> Html msg helper function that it can use to make <a/> elements that correspond to a msg in your app. These special <a/> elements that it creates interact with your update function much more cleanly, their target URLs are automatically generated using the other functions you’ve already provided to RouteUrl, etc. No more choosing the lesser evil between a href and div onClick. :slight_smile:

Unfortunately I haven’t had time to write documentation for it, so you’ll have to see what more you can gather from the code changes and my sandbox test code.

1 Like

Thanks. I was more looking for something at the language level, something quick and simple for rapid prototyping.

The testing story demonstrated on their site in the “Write UI tests” section looks pretty sweet though :slight_smile:

I don’t think I fully understand that double update problem. And couldn’t understand nor compile your code unfortunately :frowning:

I’ve managed to initialize my form values from the URL but it’s buggy: if I change one form input, the other input reverts to its init state, from before the first ChangeUrl message was called.

So it seems both init and ChangeUrl compete for data access in a problematic way for me here.

In fact, using Browser.application seems to lead me to a dead end here as Browser.application requires me to initialize my formModel via init, but I want to initialize its state from the URL via ChangeUrl which comes after init.

Is this what you mean by onUrlRequest / onUrlChange being tedious to work with?

On a side note, since posting I stumbled upon this cool project: https://www.elm-spa.dev/

So hopefully I’ll find some intersting ideas there.

module Main exposing (main)

import Browser exposing (Document, UrlRequest(..))
import Browser.Navigation as Nav
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onInput)
import Url exposing (Url)
import Url.Parser as UP exposing ((<?>))
import Url.Parser.Query as Q



-- Next, I need to do this:
-- history.pushState(null, '', '/#form?a=b')
--
--
-- ROUTING


type alias FirstName =
    String


type alias LastName =
    String


type Route
    = FormPage (Maybe FirstName) (Maybe LastName)
    | ListPage


routeParser : UP.Parser (Route -> b) b
routeParser =
    UP.oneOf
        [ UP.map FormPage <| UP.s "form" <?> Q.string "fName" <?> Q.string "lName"
        , UP.map ListPage (UP.s "list")
        ]



-- MAIN


main : Program () Model Msg
main =
    Browser.application
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        , onUrlRequest = ClickLink
        , onUrlChange = ChangeUrl
        }



-- MODEL


type alias FormModel =
    { firstName : String
    , lastName : String
    , dirty : Bool
    }


type alias Model =
    { key : Nav.Key
    , route : Maybe Route
    , formModel : FormModel
    }


init : () -> Url -> Nav.Key -> ( Model, Cmd Msg )
init _ url key =
    ( { key = key
      , route = UP.parse routeParser url
      , formModel = FormModel "A" "B" False
      }
    , Cmd.none
    )



-- UPDATE


type Msg
    = ChangeUrl Url
    | ClickLink UrlRequest
    | ChangeFirstName String
    | ChangeLastName String


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ChangeUrl url ->
            ( { model | route = UP.parse routeParser url }
            , Cmd.none
            )

        ClickLink urlRequest ->
            case urlRequest of
                Internal url ->
                    ( model
                    , Nav.pushUrl model.key (Url.toString url)
                    )

                External url ->
                    ( model, Nav.load url )

        ChangeFirstName val ->
            ( { model | formModel = FormModel val model.formModel.lastName True }
            , Cmd.none
            )

        ChangeLastName val ->
            ( { model | formModel = FormModel model.formModel.firstName val True }
            , Cmd.none
            )



-- VIEW


view : Model -> Document Msg
view model =
    let
        navBar =
            let
                padding =
                    style "margin" "20px"
            in
            div []
                [ a [ padding, href "#/form" ] [ text "Form" ]
                , a [ padding, href "#/list" ] [ text "List" ]
                ]

        content =
            case model.route of
                Just a ->
                    case a of
                        FormPage fn ln ->
                            viewForm model.formModel fn ln

                        ListPage ->
                            viewList

                Nothing ->
                    p [] [ text "Kinda 404" ]
    in
    { title = "Just a SPA"
    , body =
        [ navBar
        , content
        ]
    }


selectFormValue : Bool -> String -> Maybe String -> String
selectFormValue dirty dirtyVal initVal =
    if dirty then
        dirtyVal

    else
        case initVal of
            Just val ->
                val

            Nothing ->
                ""


viewForm : FormModel -> Maybe FirstName -> Maybe LastName -> Html Msg
viewForm fm initFn initLn =
    let
        fnVal =
            selectFormValue fm.dirty fm.firstName initFn

        lnVal =
            selectFormValue fm.dirty fm.lastName initLn
    in
    div []
        [ h1 [] [ text "A form" ]
        , Html.form []
            [ input [ type_ "text", value fnVal, onInput ChangeFirstName ] []
            , input [ type_ "text", value lnVal, onInput ChangeLastName ] []
            ]
        ]


viewList : Html msg
viewList =
    div []
        [ h1 [] [ text "A list" ]
        , ul []
            [ li [] [ text "A" ]
            , li [] [ text "B" ]
            , li [] [ text "C" ]
            ]
        ]

If you already had the Msg that is going to be used with the HTTP request, you could use Process.sleep, Task.andThen, and Task.succeed, like such, no library required:

type Msg
    = ... 
    | RxHttpResponse (Result Http.Error String)
    -- ^ could be used with Http.expectString. 
    -- Change as appropriate if you plan to use Http.expectJson or Http.expectBytes

fakeHttpRequest : Cmd Msg
fakeHttpRequest = 
    Process.sleep 5000 
        |> Task.andThen (\_ -> Task.succeed "Mocked String body of response: A SERVER MESSAGE")
        |> Task.attempt RxHttpResponse

If you know the parameters that you will be passing into your Http request when it’s ready, you can even change the type signature of the fakeHttpRequest function to match that:

fakeHttpRequest2 : Key -> Cmd Msg
fakeHttpRequest2 key = 
    Process.sleep 5000 
        |> Task.andThen (\_ -> Task.succeed ("Mocked String body of response for key: " ++ Key.toString key))
        |> Task.attempt RxHttpResponse

If you’d like to simulate the call failing after five seconds, you can replace Task.succeed with Task.fail.

Thanks @Enkidatron, that’s exactly what I was looking for!

I don’t understand the Key concept yet but I’ll look into it.

I had to fiddle about to make the failure case compile. There’s probably a more general concept on top of Json.Encode/Decode I could use:

fakeHttpRequestFail : Cmd Msg
fakeHttpRequestFail =
    Process.sleep 5000
        |> Task.andThen (\_ -> Task.fail (Json.Decode.Failure "Oops" (Json.Encode.object [])))
        |> Task.attempt RxHttpResponse

elm-spa looks promising but I’ll have to keep digging…

Ok, I’ve had a bit of success with elm-spa which simplified quite a lot of stuff for me.

I’d appreciate a little bit of feedback regarding this demo. If somebody knows how to easily share a runnable example, please let me know.

Here, I’m only caching data at /form. That’s ok but I still would like to not re-render /form each time I re-visit it. I’m not sure what I would need though, maybe a special router?

I also want to synchronize the form state in the URL. I haven’t found much on the subject so I suppose I’ll have to implement that feature through ports. Any other ideas?

Thanks :slight_smile:

Ok for the record, I managed to implement my little demo here (based on elm-spa)

To sync the URL with the form state I used a port and that worked very well.

I also managed to store the computed view (which is a feature I would want for performance reason), albeit it feels a bit clunky as-is.

So far so good with Elm, gotta keep digging now :slight_smile:

Sorry to unnecessarily complicate things with the Key data type. I just imagined it as a type-safe wrapper around a String, like such:

module Key exposing (Key, toString, ...)

type Key = Key String

toString : Key -> String
toString (Key innerKey) = innerKey

A type-safe wrapper like this would prevent you from mixing up this string with other strings. In retrospect, Key was not the best name for it.

If you had, for example, both “Post IDs” and “User IDs”, then you could make each of them be their own wrapped type, and then the compiler would prevent you from every mixing them up.

1 Like

Oh that’s much clearer now.

I thought you were referring to an internal concept at the time :wink:

Thanks for your help

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.