Pure in-app navigation implemented in Elm (URL become a thin wrapper instead)

# elm# functional# routing
Pure in-app navigation implemented in Elm (URL become a thin wrapper instead)Moremi Vannak

The model owns where the app is, and one wrapper around update keeps the URL in sync

In the usual Elm app the URL is the source of truth. To go somewhere, update writes the URL, and the app only moves when the URL comes back as UrlChanged. This works, but after a while I found three problems with it, so I tried the other way around: the model owns where the app is, and the URL just follows it. I am writing it down in case I forget, or in case someone else finds it useful.

The usual way: every move goes through the URL

The thin URL wrapper: the URL follows the model

You can see the sample code here: https://github.com/rinn7e/elm-navigation-project

It has the same small blog three times (a list of posts, a post, an about page, a login page, and a page to write a new post that only signed-in users can open):

  • elm-blog: the usual way, as in elm-spa-example
  • elm-blog-url-wrapper: the thin URL wrapper
  • elm-blog-url-wrapper-screen-naming: the same as the second one, with other names

The test account is test / testtest.

Requirement

This post aims for those who are already familiar with Elm and Browser.application.

Every tool is a local dev dependency (Elm, elm-format, Vite with vite-plugin-elm, Tailwind v4), so in each app folder this is enough:

npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

The usual way

Here are the branches of update in elm-blog that write the URL:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case ( msg, model.page ) of
        ( LinkClicked urlRequest, _ ) ->
            case urlRequest of
                Browser.Internal url ->
                    ( model, Nav.pushUrl model.key (Url.toString url) )

                Browser.External href ->
                    ( model, Nav.load href )

        ( UrlChanged url, _ ) ->
            changeRouteTo (Route.fromUrl url) model

        ( ClickedLogout, _ ) ->
            ( { model | user = Nothing }
            , Cmd.batch [ storeUser Nothing, Route.replaceUrl model.key Route.Home ]
            )

        ( GotLoginMsg subMsg, Login login ) ->
            case Login.update subMsg login of
                ( _, Just user ) ->
                    ( { model | user = Just user }
                    , Cmd.batch [ storeUser (Just user), Route.replaceUrl model.key Route.Home ]
                    )

        -- ...
Enter fullscreen mode Exit fullscreen mode

And the guard lives in changeRouteTo, where the URL comes back:

        Just Route.NewPost ->
            if model.user == Nothing then
                ( model, Route.replaceUrl model.key Route.Login )

            else
                ( { model | page = NewPost NewPost.init }, Cmd.none )
Enter fullscreen mode Exit fullscreen mode

The problems with this are:

  1. Navigation is spread over update. Every branch that wants to move the app has to write the URL itself. A new branch that forgets it leaves the URL showing something other than the screen.
  2. The echo. Everything the app writes comes back as UrlChanged, so one move is two updates: one that writes the URL, and one that really changes the page. I wrote about this before in Solving Elm Router "Double Update" Problem, where I needed an isInternal flag to ignore it. With a guard it is even more: a guest clicking "Write" is three messages and two URL writes (LinkClicked, push /new, UrlChanged /new, replace /login, UrlChanged /login).
  3. The sync. Between the write and the echo, the model and the URL do not agree. Right after a login, model.user is set but the page is still the login page until UrlChanged arrives.

The idea

The URL is not state. It shows where the app is, the same way the view shows what the app holds, so it can be written from the model too:

  • The model owns where the app is (the destination). update never touches the URL.
  • One wrapper around the root update writes the URL after every step, from the destination read from the model. A change of destination pushes an entry, the same destination writes nothing.
  • The URL is never the source of truth: it is only parsed into a move at boot, on back / forward, and when a link is clicked (Elm hands links to us as URLs). The guard runs on every move, and when it redirects, the URL is corrected with a replace.
  • Only one module (Route) knows how a destination is spelled as a URL.

The thin URL wrapper

The Route module and the pages do not change at all. Everything below is in Main.elm of elm-blog-url-wrapper.

1. The destination is read, not stored

The page in the model already says where the app is, so the destination is not stored a second time. It is read from the page:

destinationOf : Model -> Maybe Route
destinationOf model =
    case model.page of
        NotFound ->
            Nothing

        Home ->
            Just Route.Home

        Post slug ->
            Just (Route.Post slug)

        About ->
            Just Route.About

        Login _ ->
            Just Route.Login

        NewPost _ ->
            Just Route.NewPost
Enter fullscreen mode Exit fullscreen mode

In a bigger app each part of the model keeps its own piece (the screen shown, an open dialog, a tab) and destinationOf puts them together.

2. Navigating is a model change

A move goes through the guard, and the destination shown already is dropped. It returns a Model, there is no Cmd:

navigate : Maybe Route -> Model -> Model
navigate maybeRoute model =
    let
        destination =
            guard model maybeRoute
    in
    if destination == destinationOf model then
        model

    else
        openPage destination model


guard : Model -> Maybe Route -> Maybe Route
guard model maybeRoute =
    case maybeRoute of
        Just Route.Login ->
            if model.user /= Nothing then
                Just Route.Home

            else
                maybeRoute

        Just Route.NewPost ->
            if model.user == Nothing then
                Just Route.Login

            else
                maybeRoute

        _ ->
            maybeRoute
Enter fullscreen mode Exit fullscreen mode

openPage is the old changeRouteTo without the guard and without the Cmd.

So the branches of update only change the model. A login sets the user and navigates home, and nothing in update knows about the URL:

        ( LinkClicked urlRequest, _ ) ->
            case urlRequest of
                Browser.Internal url ->
                    ( navigate (Route.fromUrl url) model, Cmd.none )

                Browser.External href ->
                    ( model, Nav.load href )

        ( UrlChanged url, _ ) ->
            ( navigate (Route.fromUrl url) model, Cmd.none )

        ( ClickedLogout, _ ) ->
            ( navigate (Just Route.Home) { model | user = Nothing }
            , storeUser Nothing
            )

        ( GotLoginMsg subMsg, Login login ) ->
            case Login.update subMsg login of
                ( _, Just user ) ->
                    ( navigate (Just Route.Home) { model | user = Just user }
                    , storeUser (Just user)
                    )

        -- ...
Enter fullscreen mode Exit fullscreen mode

Links stay a tags with an href (open in a new tab still works). Elm sends every plain click on them to onUrlRequest, so LinkClicked is just one more move: it opens the page, it does not push the URL.

3. The wrapper

This is the only place that writes the URL:

updateWithUrl : Msg -> Model -> ( Model, Cmd Msg )
updateWithUrl msg model =
    let
        ( newModel, cmd ) =
            update msg model
    in
    ( newModel, Cmd.batch [ urlCmd msg model newModel, cmd ] )


urlCmd : Msg -> Model -> Model -> Cmd Msg
urlCmd msg model newModel =
    case ( msg, destinationOf newModel ) of
        ( _, Nothing ) ->
            -- The not found page writes no URL: no link of the app leads to it
            Cmd.none

        ( UrlChanged url, Just route ) ->
            if Route.fromUrl url == Just route then
                Cmd.none

            else
                Route.replaceUrl newModel.key route

        ( _, Just route ) ->
            if destinationOf model == Just route then
                Cmd.none

            else if guard newModel (destinationOf model) /= destinationOf model then
                -- The guard would redirect the destination before
                Route.replaceUrl newModel.key route

            else
                Route.pushUrl newModel.key route
Enter fullscreen mode Exit fullscreen mode

And it replaces update in the program:

main : Program (Maybe String) Model Msg
main =
    Browser.application
        { init = init
        , view = view
        , update = updateWithUrl
        , subscriptions = \_ -> Sub.none
        , onUrlRequest = LinkClicked
        , onUrlChange = UrlChanged
        }
Enter fullscreen mode Exit fullscreen mode

A back or forward of the browser (UrlChanged) has its entry already, so it is only corrected when the guard redirected it. Every other step pushes a new destination, with one exception: when the guard would now redirect the destination you left, its entry is replaced. After a login the login page would only redirect home, so back goes to the page before the login. The same after logging out on /new.

Apart from telling a back / forward from the rest, the wrapper decides from the state (the destination before and after the step), not from which branch of update moved the app. So it cannot be forgotten.

The echo is still there in Elm (Nav.pushUrl and Nav.replaceUrl send UrlChanged), but now it is harmless: it is the destination shown, so navigate drops it and the wrapper writes nothing. No isInternal flag is needed.

4. Boot

At boot the URL is read once. The page is opened through the guard right away, and the URL is corrected with a replace only when the guard redirected it:

init : Maybe String -> Url -> Nav.Key -> ( Model, Cmd Msg )
init user url key =
    let
        urlDestination =
            Route.fromUrl url

        bootModel =
            { key = key
            , user = user
            , posts = BlogPost.all
            , page = NotFound
            }

        model =
            openPage (guard bootModel urlDestination) bootModel
    in
    ( model, bootUrlCmd urlDestination model )
Enter fullscreen mode Exit fullscreen mode

bootUrlCmd compares urlDestination with destinationOf model and calls Route.replaceUrl when they differ.

What changed

In src, only Main.elm changed from elm-blog. You can check with:

git diff --no-index elm-blog/src elm-blog-url-wrapper/src
Enter fullscreen mode Exit fullscreen mode
  • update never writes the URL: each branch only changes the model.
  • The guard runs on every move, before the page opens: at boot, on back / forward, and inside the app.
  • A guest clicking "Write" is two messages (LinkClicked, and the echo of /login, which is dropped) and one URL write. /new is never written.
  • Logging out from a page the guard still allows (/about) now pushes /, so back returns to that page. In elm-blog it replaced the entry.
  • The model and the URL agree after every step. The not found page is the exception: it writes no URL, which is fine because no link of the app leads to it.

Conclusion

That is it. The URL becomes a thin adapter at the edges: written from the model after every step, and parsed into a move only when the browser hands one to us.

If the naming makes it harder to follow, it is because Route and Page are names from the URL-first way. In elm-blog-url-wrapper-screen-naming I changed the names only: Page is Screen (what the app shows) and Route is Destination (where the app is):

type Screen
    = NotFound
    | Home
    | Post String
    | About
    | Login Login.Model
    | NewPost NewPost.Model
Enter fullscreen mode Exit fullscreen mode
destinationOf : Model -> Maybe Destination
destinationOf model =
    case model.screen of
        NotFound ->
            Nothing

        Home ->
            Just Destination.Home

        -- ...
Enter fullscreen mode Exit fullscreen mode

With these names the model holds a screen, and the destination is read from it.

I hope to hear from others if they reach the same conclusion. Feel free to ask me anything as well.

Thanks for reading!