Elm IV: Navigation!

navigation.jpg

Last week, we learned a few more complexities about how Elm works. We examined how to bridge Elm types and Haskell types using the elm-bridge library. We also saw a couple basic ways to incorporate effects into our Elm application. We saw how to use a random generator and how to send HTTP requests.

These forced us to stretch our idea of what our program was doing. Our original Todo application only controlled a static page with the sandbox function. But this new program used element to introduce effects into our program structure.

But there's still another level for us to get to. Pretty much any web app will need many pages, and we haven't seen what navigation looks like in Elm. To conclude this series, let's see how we incorporate different pages. We'll need to introduce a couple more components into our application for this.

Simple Navigation

Now you might be thinking navigation should be simple. After all, we can use normal HTML elements on our page, including the a element for links. So we'd set up different HTML files in our project and use routes to dictate which page to visit. Before Elm 0.19, this was all you would do.

But this approach has some key performance weaknesses. Clicking a link will always lead to a page refresh which can be disrupting for the user. This approach will also lead us to do a lot of redundant loading of our library code. Each new page will have to reload the generated Javascript for Data.String, for example. The latest version of Elm has a new solution for this that fits within the Elm architecture.

An Application

In our previous articles, we described our whole application using the element function. But now it's time to evolve from that definition. The application function provides us the tools we need to build something bigger. Let's start by looking at its type signature (see the appendix at the bottom for imports):

application :
  { init : flags -> Url -> Key -> (model, Cmd msg)
  , view : model -> Document msg
  , update : msg -> model -> (model, Cmd msg)
  , subscriptions : model -> Sub msg
  , onUrlRequest : UrlRequest -> msg
  , onUrlChange : Url -> msg
  }
  - > Program flags model msg

There are a couple new fields to this application function. But we can start by looking at the changes to what we already know. Our init function now takes a couple extra parameters, the Url and the Key. Getting a Url when our app launches means we can display different content depending on what page our users visit first. The Key is a special navigation tool we get when our app starts that helps us in routing. We need it for sending our own navigation commands.

Our view and update functions haven't really changed their types. All that's new is the view produces Document instead of only Html. A Document is a wrapper that lets us add a title to our web page, nothing scary. The subscriptions field has the same type (and we'll still ignore it for the most part).

This brings us to the new fields, onUrlRequest and onUrlChange. These intercept events that can change the page URL. We use onUrlChange to update our page when a user changes the URL at the top bar. Then we use onUrlRequest to deal with a links the user clicks on the page.

Basic Setup

Let's see how all these work by building a small dummy application. We'll have three pages, arbitrarily titled "Contents", "Intro", and "Conclusion". Our content will just be a few links allowing us to navigate back and forth. Let's start off with a few simple types. For our program state, we store the URL so we can configure the page we're on. We also store the navigation key because we need it to push changes to the page. Then for our messages, we'll have constructors for URL requests and changes:

type AppState = AppState
  { url: Url
  , navKey : Key
  }

type AppMessage =
  NoUpdate |
  ClickedLink UrlRequest |
  UrlChanged Url

When we initialize this application, we'll pass the URL and Key through to our state. We'll always start the user at the contents page. We cause a transition with the pushUrl command, which requires we use the navigation key.

appInit : () -> Url -> Key -> (AppState, Cmd AppMessage)
appInit _ url key =
  let st = AppState {url = url, navKey = key}
  in (st, pushUrl key "/contents")

Updating the URL

Now we can start filling in our application. We've got message types corresponding to the URL requests and changes, so it's easy to fill those in.

main : Program () AppState AppMessage
main = Browser.application
  { init : appInit
  , view = appView
  , update = appUpdate
  , subscriptions = appSubscriptions
  , onUrlRequest = ClickedLink -- Use the message!
  , onUrlChanged = UrlChanged
  }

Our subscriptions, once again, will be Sub.none. So we're now down to filling in our update and view functions.

The first real business of our update function is to handle link clicks. For this, we have to break the UrlRequest down into its Internal and External cases:

appUpdate : AppMessage -> AppState -> (AppState, Cmd AppMessage)
appUpdate msg (AppState s) = case msg of
  NoUpdate -> (AppState s, Cmd.none)
  ClickedLink urlRequest -> case urlRequest of
    Internal url -> …
    External href -> ...

Internal requests go to pages within our application. External requests go to other sites. We have to use different commands for each of these. As we saw in the initialization, we use pushUrl for internal requests. Then external requests will use the load function from our navigation library.

appUpdate : AppMessage -> AppState -> (AppState, Cmd AppMessage)
appUpdate msg (AppState s) = case msg of
  NoUpdate -> (AppState s, Cmd.none)
  ClickedLink urlRequest -> case urlRequest of
    Internal url -> (AppState s, pushUrl s.navKey (toString url))
    External href -> (AppState s, load href)

Once the URL has changed, we'll have another message. The only thing we need to do with this one is update our internal state of the URL.

appUpdate : AppMessage -> AppState -> (AppState, Cmd AppMessage)
appUpdate msg (AppState s) = case msg of
  NoUpdate -> (AppState s, Cmd.none)
  ClickedLink urlRequest -> …
  UrlChanged url -> (AppState {s | url = url}, Cmd.None)

Rounding out the View

Now our application's internal logic is all set up. All that's left is the view! First let's write a couple helper functions. The first of these will parse our URL into a page so we know where we are. The second will create a link element in our page:

type Page =
  Contents |
  Intro |
  Conclusion |
  Other

parseUrlToPage : Url -> Page
parseUrlToPage url =
  let urlString = toString url
  in if contains "/contents" urlString
       then Contents
       else if contains "/intro" urlString
         then Intro
         else if contains "/conclusion" urlString
           then Conclusion
           else Other


link : String -> Html AppMessage
link path = a [href path] [text path]

Finally let's fill in a view function by applying these:

appView : AppState -> Document AppMessage
appView (AppState st) =
  let body = case parseUrlToPage st.url of
               Contents -> div [] 
                 [ link "/intro", br [] [], link "/conclusion" ]
               Intro -> div [] 
                 [ link "/contents", br [] [], link "/conclusion" ]
               Conclusion -> div [] 
                 [ link "/intro", br [] [], link "/contents" ]
               Other -> div [] [ text "The page doesn't exist!" ]
  in  Document "Navigation Example App" [body]

And now we can navigate back and forth among these pages with the links!

Conclusion

In this last part of our series, we completed the development of our Elm skills. We learned how to use an application to achieve the full power of a web app and navigate between different pages. There's plenty more depth we can get into with designing an Elm application. For instance, how do you structure your message types across your different pages? What kind of state do you use to manage your user's experience. We'll explore these another time.

We're not done with functional frontend yet though! We've got another series coming up that'll teach you the basics of Purescript. So stay tuned for that!

And you'll also want to make sure your backend skills are up to snuff as well! Read our Haskell Web Series for more details on that! You can also download our Production Checklist!

Appendix: Imports

import Browser exposing (application, UrlRequest(..), Document)
import Browser.Navigation exposing (Key, load, pushUrl)
import Html exposing (button, div, text, a, Html, br)
import Html.Attributes exposing (href)
import Html.Events exposing (onClick)
import String exposing (contains)
import Url exposing (Url, toString)