Moremi VannakThe 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.
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 wrapperelm-blog-url-wrapper-screen-naming: the same as the second one, with other namesThe test account is test / testtest.
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
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 ]
)
-- ...
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 )
The problems with this are:
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.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).model.user is set but the page is still the login page until UrlChanged arrives.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:
update never touches the URL.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.Route) knows how a destination is spelled as a URL.The Route module and the pages do not change at all. Everything below is in Main.elm of elm-blog-url-wrapper.
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
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.
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
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)
)
-- ...
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.
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
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
}
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.
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 )
bootUrlCmd compares urlDestination with destinationOf model and calls Route.replaceUrl when they differ.
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
update never writes the URL: each branch only changes the model.LinkClicked, and the echo of /login, which is dropped) and one URL write. /new is never written./about) now pushes /, so back returns to that page. In elm-blog it replaced the entry.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
destinationOf : Model -> Maybe Destination
destinationOf model =
case model.screen of
NotFound ->
Nothing
Home ->
Just Destination.Home
-- ...
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!