The missing part of the Elm guide: URL handling with Browser.application

I saw a tweet from someone who was confused by the missing section in the URL Parsing chapter of the Elm 0.19 guide.

The chapter shows several examples of URL parsers made with Url.Parser, but the Synthesis section at the end is just a TODO. However, the section does suggest what should be in it:

The major new things are:

  1. Our update parses the URL when it gets a UrlChanged message.
  2. Our view function shows different content for different addresses!

It is really not too fancy. Nice!

While it's not too fancy, it's not trivial either, so let's walk through how we would use the Browser.application function to setup our app to achieve these goals, and additionally to make it handle links that result in view changes. Browser.application is one of the functions replacing Html.program and friends in Elm 0.19 applications, so it's essential to know.

Let's base it on the URL parser for a documentation site URL parser shown in example 3 in the guide:

type alias DocsRoute =
  (String, Maybe String)

docsParser : Parser (DocsRoute -> a) a
docsParser =
  map Tuple.pair (string </> fragment identity)

-- /Basics     ==>  Just ("Basics", Nothing)
-- /Maybe      ==>  Just ("Maybe", Nothing)
-- /List       ==>  Just ("List", Nothing)
-- /List#map   ==>  Just ("List", Just "map")
-- /List#      ==>  Just ("List", Just "")
-- /List/map   ==>  Nothing
-- /           ==>  Nothing

Browser.application allows us to create an application that manages URL changes. Its signature is:

application :
    { init : flags -> Url -> Key -> ( model, Cmd msg )
    , view : model -> Document msg
    , update : msg -> model -> ( model, Cmd msg )
    , subscriptions : model -> Sub msg
    , onUrlRequest : UrlRequest -> msg
    , onUrlChange : Url -> msg
    }
    -> Program flags model msg

The view is a function that produces Document msg, same as when using Browser.document. init is a bit more complicated as it also receives Url and Key as its arguments.

There are two new fields in the record in addition to the usual init, view, update and subscriptions: onUrlRequest and onUrlChange.

This is how we will use it:

module Main exposing (main)

import Browser exposing (Document, UrlRequest(..))
import Browser.Navigation as Nav
import Html exposing (..)
import Html.Attributes exposing (..)
import Url exposing (Url)
import Url.Parser as UrlParser exposing ((</>))


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

In this setup, you manage the URL changes in your code. When a link is clicked, it results in a ClickLink message (whose constructor we supplied as onUrlRequest). In response to this message in the update function, you can do whatever you need (such as saving the state of input) before changing the URL using one of the functions from Browser.Navigation.

When the URL actually changes, it results in the ChangeUrl message (whose constructor we supplied as onUrlChange).

Let's define our model and implement init:

type alias Model =
    { navKey : Nav.Key
    , route : Maybe DocsRoute
    }


init : () -> Url -> Nav.Key -> ( Model, Cmd Msg )
init _ url navKey =
    ( { navKey = navKey, route = UrlParser.parse docsParser url }, Cmd.none )

The navKey field in the model is going to store the Nav.Key value passed to init. This key is used to make sure that only programs using Browser.application are able to use the functions in Browser.Navigation to change the URL.

The route field will store DocsRoute tuples extracted from the URL with the help of docsParser from the Elm guide. In fact, we're also using this parser in init to set the initial route from the URL that init receives.

We also need to define our Msg type:

type Msg
    = ChangeUrl Url
    | ClickLink UrlRequest

As you see, both messages carry some data. This is how we can handle them:

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

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

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

Clicking a link produces a ClickLink message that carries a UrlRequest value. This value tells us whether the request is for an internal or an external URL. In the case of internal URLs, we use Browser.Navigation.pushUrl to change the URL and add an entry to the browser history. In the case of external URLs, we use Browser.Navigation.load which requests the URL and does a page load.

Note that we have to pass navKey to pushUrl.

Both pushUrl and load return commands because changing the URL is a side effect.

When the URL actually changes, update receives a ChangeUrl message with the new URL. We apply docsParser to that URL and store the route in the model.

Finally, we need to display some content based on the route. In real life, it would be different documentation pages, but here I'm only going to go as far as showing different page titles:

view : Model -> Document Msg
view model =
    let
        inline =
            style "display" "inline-block"

        padded =
            style "padding" "10px"

        menu =
            div [ style "padding" "10px", style "border-bottom" "1px solid #c0c0c0" ]
                [ a [ inline, padded, href "/Basics" ] [ text "Basics" ]
                , a [ inline, padded, href "/Maybe" ] [ text "Maybe" ]
                , a [ inline, padded, href "/List" ] [ text "List" ]
                , a [ inline, padded, href "/List#map" ] [ text "List.map" ]
                , a [ inline, padded, href "/List#filter" ] [ text "List.filter" ]
                ]

        title =
            case model.route of
                Just route ->
                    Tuple.first route
                        ++ (case Tuple.second route of
                                Just function ->
                                    "." ++ function

                                Nothing ->
                                    ""
                           )

                Nothing ->
                    "Invalid route"
    in
    { title = "URL handling example"
    , body =
        [ menu
        , h2 [] [ text title ]
        ]
    }

There a couple of things to note here:

  • When defining the menu in the let, we set the href attribute of the links in the format understood by docsParser.

  • title in the let is where we decide what title to show based on the current route. We could show the actual documentation in a similar fashion.

And that's it!

Next steps

  1. I assume you're reading this post because you're learning Elm. If you've been tinkering with Elm for a while and still find it tricky to imagine how you'd write a complex real world app in it, check out my book Practical Elm. It's an intermediate level book: no basics, a deep dive into the capabilities of the language, and a lot of advice on practical development issues.

  2. Below is the whole program for convenience. You can't run it in Ellie, so if you want to try it, you'll need to start a project locally.

elm.json

{
    "type": "application",
    "source-directories": [
        "src"
    ],
    "elm-version": "0.19.0",
    "dependencies": {
        "direct": {
            "elm/browser": "1.0.0",
            "elm/core": "1.0.0",
            "elm/html": "1.0.0",
            "elm/url": "1.0.0"
        },
        "indirect": {
            "elm/json": "1.0.0",
            "elm/time": "1.0.0",
            "elm/virtual-dom": "1.0.2"
        }
    },
    "test-dependencies": {
        "direct": {},
        "indirect": {}
    }
}

src/Main.elm

module Main exposing (main)

import Browser exposing (Document, UrlRequest(..))
import Browser.Navigation as Nav
import Html exposing (..)
import Html.Attributes exposing (..)
import Url exposing (Url)
import Url.Parser as UrlParser exposing ((</>))


type Msg
    = ChangeUrl Url
    | ClickLink UrlRequest


type alias DocsRoute =
    ( String, Maybe String )


type alias Model =
    { navKey : Nav.Key
    , route : Maybe DocsRoute
    }


init : () -> Url -> Nav.Key -> ( Model, Cmd Msg )
init _ url navKey =
    ( { navKey = navKey, route = UrlParser.parse docsParser url }, Cmd.none )


docsParser : UrlParser.Parser (DocsRoute -> a) a
docsParser =
    UrlParser.map Tuple.pair (UrlParser.string </> UrlParser.fragment identity)


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

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

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


view : Model -> Document Msg
view model =
    let
        inline =
            style "display" "inline-block"

        padded =
            style "padding" "10px"

        menu =
            div [ style "padding" "10px", style "border-bottom" "1px solid #c0c0c0" ]
                [ a [ inline, padded, href "/Basics" ] [ text "Basics" ]
                , a [ inline, padded, href "/Maybe" ] [ text "Maybe" ]
                , a [ inline, padded, href "/List" ] [ text "List" ]
                , a [ inline, padded, href "/List#map" ] [ text "List.map" ]
                , a [ inline, padded, href "/List#filter" ] [ text "List.filter" ]
                ]

        title =
            case model.route of
                Just route ->
                    Tuple.first route
                        ++ (case Tuple.second route of
                                Just function ->
                                    "." ++ function

                                Nothing ->
                                    ""
                           )

                Nothing ->
                    "Invalid route"
    in
    { title = "URL handling example"
    , body =
        [ menu
        , h2 [] [ text title ]
        ]
    }


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

Further reading

Browser.application documentation

Browser.Navigation documentation

Comments or questions? I'm @alexkorban on Twitter.