More on SPA navigation in Elm

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

Wouter In t Velt
Jan 11, 2017 · 6 min read
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 ) 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 ), 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 type is still used, but only in . It is not stored in the model. Instead, a new type 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 branch of the 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 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 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 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 function also takes an initial location (with a URL) as a parameter. So you will also need to handle the URL in . So what I typically do, is extract the entire branch for checking and processing a new URL into a separate function called (with a bit of 0.17 nostalgia). It has the following signature:

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

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

In the view function (and helpers), I find it easiest to always use 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 + a branch in my update function. This branch not only outputs the new model, but also a 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.

Elm shorts

Random examples, patterns, ideas, tutorials and other stuff.

Wouter In t Velt

Written by

Corporate Digital Transformation Manager by day, Developing in Elm by night. Fascinated by UX, FP and readable code.

Elm shorts

Random examples, patterns, ideas, tutorials and other stuff. Mostly short posts, sometimes longer. Things I learned in playing with the beautiful language Elm, with help from its awesome community. Hopefully these posts will help you in learning Elm and improving your skills.

Wouter In t Velt

Written by

Corporate Digital Transformation Manager by day, Developing in Elm by night. Fascinated by UX, FP and readable code.

Elm shorts

Random examples, patterns, ideas, tutorials and other stuff. Mostly short posts, sometimes longer. Things I learned in playing with the beautiful language Elm, with help from its awesome community. Hopefully these posts will help you in learning Elm and improving your skills.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store