More on SPA navigation in Elm

Keep current page and browser bar in sync, and add data to routes

Photo credit: jingoba | pixabay

Navigation and SPA are a popular topic these days in Elm-land, with lots of good stuff, like:

  • Selectors as an intermediary between model (pure data) and viewModel (derived version, fit for rendering): Medium Post by Charlie Kosten here.
  • Elm Models as Types, a way structuring your model in Union Types rather than straight records, to get better readibility and improved making impossible states impossible. Medium Post by Charlie Koster here.
  • Taco, a library to extend (view) functions to take in more than just the model, to facilitate sharing application-wide data across various modules of your SPA. Github repo by Ossi Hanhinen here.

These ideas and patterns sort of intersect with the setup I use. But on some points there are differences, so I like to share these ideas here as well.

A simple navigation structure may look something like this:

Simple app flow for the example: show details and go back

There’s a couple of ways you can implement navigation in Elm:

  1. The URL determines model state*.
  2. Model state determines URL.
  3. Keep model model state and URL in sync at all times*.
* If you want the URL to determine the model state, allowing users to type the URL in the browser bar, then the easiest way to do this is with a hash (like main#/movies) in the URL. Using a hash will make the browser stay on the same page, and not reload the page by making a server request. You can make it work with plain URLs (like main/movies), but then you need to 
a) set up your server to always return the same page and 
b) implement additional stuff to preserve your model state (reloading the page will reset your app’s model).

In this post the 3rd kind is implemented.

Also, I really wanted my app to behave in a very specific (and more user-friendly) way: 
only show a route (page) if all data for that page is available

Friendly list navigation : only show details page if details are there..

For this, I set up my navigation in a different way. The Route type is still used, but only in Msg. It is not stored in the model. Instead, a new type Page is stored in the model.

The new type Page = Route with Data, essentially making impossible routes impossible.

Sync page and browser bar — the logic

To make all this work, the app needs the following flow for dealing with a new url:

Flow to keep browser bar and current page in sync

The check whether the route changed is to prevent endless circular updates.

If the data is available, the model is updated. If the data needs to be fetched from a server, you can set a loading state in the model. In that case, the users stays at the current page while the data is loading. So you need to set the url back to the url for the current page.

Later, when the data does come in, the flow is something like this:

Flow for when the data comes in

If the data received belongs to the fetch request made, then the model is updated. At this time, you also need to set the url to the new route.

In code —Routes and helpers

To use this in my code, I define some additional types and helpers:

In code — the update function

Below is the UrlChange branch of the update function:

case Route.parse newLocation of
Nothing ->
( { model | message = "invalid URL: " ++ newLocation.hash }
, Route.modifyUrl model.currentPage
)
  Just validRoute ->
if Route.isEqual validRoute model.currentPage then
( model , Cmd.none )
else
case validRoute of
Home ->
( { model | currentPage = HomePage}
, Cmd.none
)

Movies ->
( { model
| currentPage = MoviesPage (Dict.toList model.movies)
}
, Cmd.none
)

MovieDetail id ->
( { model
| serverRequest = Just id
, message =
"Loading data for movie : " ++ toString id
}
, Cmd.batch
[ fetchMovieDetail id
, Route.modifyUrl model.currentPage
]
)

Better view functions

The greatest benefit of saving the data inside the Page in the model, is that view functions will become much easier to build.

view : Model -> Html Msg
view model =
case model.currentPage of
HomePage ->
homeView model

MoviesPage movies ->
moviesView model movies

MovieDetailPage movieId movie ->
moviesDetailView model movieId movie

You pass the data from the Page directly to the view helper for that page. The view function for a page can now rely on the fact that the data to render the page is there. No more (unnecessary) checking for Maybe data in our view functions. No more presenting users with an empty “please hold while we load your data” page. The page for movie detail data will only be displayed, and can only exist, if there is actual movie detail data to be displayed.


Other considerations with this approach

In a setup like this, navigation to a new URL may lead to multiple update cycles. One to process the URL, and another one as a result from keeping the browser bar in sync if the new url does not (yet) result in a new route in our model.

When the app is initialized, the init function also takes an initial location (with a URL) as a parameter. So you will also need to handle the URL in init. So what I typically do, is extract the entire branch for checking and processing a new URL into a separate function called urlUpdate (with a bit of 0.17 nostalgia). It has the following signature:

urlUpdate : Location -> Model -> ( Model, Cmd Msg )

And it is called from the init function as well as from the UrlChange branch of theupdate function.

In the view function (and helpers), I find it easiest to always use a [ href .. ] to trigger navigation actions. As a bonus, this also works somewhat better on mobile devices with accessibility options turned on.

When scaling an app like this, everything that can be viewed as a separate page, I build as a separate route following the pattern above. So these would include (for the example used here):

  • Adding a new movie to the list.
  • Full-page datepicker to edit publication date.
  • Separate settings page.
  • etcetera.

For any other action that is not a full page, I would include different messages (not routes) in my model, like:

  • Opening a dropdown for e.g. movie rating.
  • Any other on-page editing actions.
  • Receiving data from server.
  • Deleting a movie from the list.

For the last one, I have a separate Msg + a branch in my update function. This branch not only outputs the new model, but also a Route.modifyRoute Movies command, to instruct the elm runtime to go back to the overview route (of course we cannot stay at the details page of the movie we just deleted).

So far, the SPA setup outlined in this post is working pretty well for me. Hope you get some use out of it too.

Show your support

Clapping shows how much you appreciated Wouter In t Velt’s story.