More on SPA navigation in Elm
Keep current page and browser bar in sync, and add data to routes
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:
There’s a couple of ways you can implement navigation in Elm:
- The URL determines model state*.
- Model state determines URL.
- 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 (likemain/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
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:
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:
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.