Choosing the right Elm SPA architecture
And how this impacts your users and your code
There are lots of different ways to setup navigation in Elm. E.g. URLs with or without hashes. You can render links in your views as a [ href "/movies" ]
or use [ onClick (RouteChange Movies) ]
. Lots of choices to make, and each choice comes with its own specific consequences for users and for your code.
I was looking for a way to set up SPA navigation in my Elm app that best fitted my needs. So I set out to bring some structure in the choices to make and their implications.
There is no one best way to do SPA navigation in Elm. But hopefully the overview below will help you choose the right setup for your situation.
Choice #1: URLs with or without hashes?
One of the questions for you is whether you (and possibly your user) are OK with using a hash (#
) in the url. Personally, I would prefer a hash-lessmydomain.com/movies/time-bandits
overmydomain.com/#/movies/time-bandits
.
The first one looks cleaner, is the same as the purely server-side rendered version. So probably easier to read/write for the user too.
But the hash symbol does have a formal role. Any browser will interpret everything after the #
as being “on the current page”. So it will send a reload request to the server. Which in a SPA is exactly what we want: we want the one Single Page Application to be in control of what is rendered.
If you are just starting to fiddle with Navigation, or you are OK with the hash, or you do not care either way, go with hashed URLs. The local state of your app will always be preserved.
If you do want hash-less URLs, you have to be a bit more careful. If your view
functions use a [ href … ]
elements for internal SPA navigation, you will need to add special treatment. Otherwise, the hash-less url will cause a page reload, with loss of your apps state. The special treatment consists of adding a custom onClick
handler that prevents the default behavior of the link clicked:
a
[ href "/movies/rogue-one"
, onClickPreventDefault (UrlChange "/movies/rogue-one")
]
[ text "movie details" ]
---
onClickPreventDefault : msg -> Attribute msg
onClickPreventDefault msg =
onWithOptions
"click"
{ preventDefault = True
, stopPropagation = False
}
(Json.Decode.succeed msg)
You could also import the onClickPreventDefault
from the Html.Events.Extra package.
Whenever users type one of your hash-less URLs in the browser bar, this will always cause a page reload. The structure of Elm’s Navigation program does force you to handle an initial route, so when the page is loaded from the server, the user will end up in the right place/ on the right page. But if they were already in your app, the page will reload from server, and their local state (like scroll position, dropdowns open, and any unsaved data) will not be preserved.
Choice #2: Validate a new route before navigating to it?
Another choice to make is whether you want to validate a new route before your app navigates to it. I talked about this in a previous post. The idea is that your SPA code will validate any new route before actually navigating to it. The app stays on the current page, until the necessary data for the new page is in, and until the new route is validated.
Loading and validating on the new page has an advantage: the user will immediately and clearly notice that the app responds to navigation. And in most cases, there will be valid content to show on the new page, right?
My personal preference is to load and validate before navigating to a new page. I find it more user friendly to let the user spend any waiting time on the current page, instead of on some empty new page.
But that’s your choice really.
Choice #3: Only <a> links for navigation in your view functions?
Another choice is whether you will only use a [ href .. ]
elements in your view functions for navigation. After all, this is standard HTML for navigation links. My preference is to always use a
elements in my views for navigation.
But this may not always be an option. If you are using a UI library like mdl or elm-ui, then you may want or need to assign navigation actions to other HTML elements too.
If you do use more than just a
elements for navigation, there is still a trick you can use to make any element behave like a link. Which brings us to choice #4..
Choice# 4: Are you OK with a javascript trick for non <a>
elements in your views?
There is a trick you can use to make any element behave like a link. It uses the possibility to provide a one-line javascript statement to the attribute
from the Html.Attributes package:
div [ customRef "/movies/maleficent" ] [..]
-- Custom helper function
customRef : String -> Attribute msg
customRef path =
let
javascriptCode =
"history.pushState({},'','" ++ path ++ "');"
in
attribute
"onclick"
javascriptCode
The javascript-oneliner history.pushState()
will push a new entry to the browser’s history. This will in turn fire the UrlChange
message in your elm app, to handle the new route. To make this happen upon a user click, it is provided to the onclick
attribute of the element.
So far, this is more of a proof of concept and not an approach I would recommend. The pushState()
is supposed to work in all modern browsers, but I haven’t tested. The function takes two more arguments (state and title), which are left empty in this example. I have no idea hwo that affects your browser histroy. Also, if you want this trick to play nice with navigating back and forth (user clicking back-button etc), I haven’t tested whether this will work.
Impact on your code
🔥 The easiest path
The easiest implementation is with the following choices:
- choice #1: use URLs with hashes.
- choice #2: navigate first, and then do loading and validation.
- choice #3: only use
a
elements in your views for navigation.
In that case all you need to implement is a UrlChange
branch in your update
. Which does:
- Translate the new URL to a typesafe
Route
(this is not really necessary, but it is a good and common practice), which could includeInvalid404
or something similar to cover invalid routes. - Store the new
Route
in the model. - Kick off any loading of server data if necessary.
Your view
function should then check whether the model has a valid Route
and if the data for the route is there, and render a page accordingly.
All navigation links in the view would be straightforward: a [ href somePath ] [...]
. Where somePath
would be a String with a hashed URL, like /#/movies/brave
.
If you want instead to use hashless URLs, only the first choice would be different:
- choice #1: use hashless URLs
Then the implementation is only slightly different. In that case, the link in the views would look something like this (to prevent default page reload with the hashless link):
a
[ href somePath
, onClickPreventDefault (UrlChange somePath)
]
[ ... ]
But the structure of your update
would be the same.
🔥🔥 Navigation with other HTML elements
If you want to use not only a
elements in your view function, then the setup is different. Let’s say you make the following choices:
- choice #1: hashless
- choice #2: navigate first, then do loading and validation
- choice #3: not just
a
elements - choice #4: no javascript hack please
In that case, it is common practice to set up navigation in your view
with Route
s instead of URLs. The update function then has an extra branch, and your code would look something like this:
type Route
= Home
| Movies
| MovieDetail String
-- in view
button [ onClick <| RouteChanged (MovieDetail "species") ] [..]
-- in update
case msg of
RouteChange newRoute ->
( model, Navigation.newUrl <| urlFor newRoute )
UrlChange newLocation ->
let
...
in
(newModel, cmdToFetchData)
The RouteChange
branch leaves the model unchanged, and outputs a new URL for the browser. This will trigger another UrlChange
, where the actual update of the model will take place, possibly with a command to load data from the server.
🔥🔥🔥 Validation and loading before navigating to new page
The combination of choices not covered yet are those where you would want to do loading and validation before going to a new page. The difference with the options above is that
- Inside the
UrlChange
or theRouteChange
branch, you would include checks for whether the route is valid and the data is there, and only change the current route in your model if everything is OK. - If you do not use a
RouteChange
branch (if you use onlya
elements you do not need one), then you would need to output an additionalNavigation.modifyUrl
command in yourUrlChange
branch to revert the browser bar to the current page if your validation fails.
Further reading and credits
This post is a follow-up to another post on navigation.
That post explains in some more detail how to set up navigation with the “validate before navigation” option.
In addition, a lot of the ideas and options here came from other posts and examples.
These are good sources if you want to set up navigation for your own SPA.