Routing in Elm with Hop and Mailboxes

Josh Clayton

I’ve been enjoying Elm for a while now and, in the interest of understanding what types of applications can be built with the language, I decided to try out Hop, a routing library for single page applications that, as of version 4.0, supports push state. It also supports StartApp out of the box.

Getting Started with Hop

Hop requires a bit of wiring together to get everything working; the areas to change are:

  1. Model: The model is responsible for maintaining knowledge of where the user is
  2. View: The view is responsible for rendering the correct content based on where the user is
  3. Update: The updater/reducer is responsible for handling navigation changes and updating the model accordingly
  4. Main.elm: Main.elm manages all of our outbound ports; because Hop is interacting with the browser, this data transfer happens over a port
  5. Router: The router is responsible for managing possible routes and their constraints

With this understanding, let’s go through each section one-by-one. In this example, I’ll be referencing an Elm application available on GitHub that follows the Elm architecture; the application lives in the HopExample namespace.

Model Changes

As mentioned previously, the model now needs to maintain two pieces of information: the current route (referenced by a union type of all available routes), and the current location, made available from the browser.

First, let’s open HopExample.App.Model and update our imports:

-- HopExample.App.Model

import Hop.Types exposing (Location, newLocation)
import HopExample.Router exposing (Route(LoadingRoute))

(We’ll come back to HopExample.Router below.)

Hop.Types provides the Location type and a newLocation function, which is the base state of Location. You can see that, from our HopExample.Router, we’re making available LoadingRoute, a nullary data constructor that’s part of the Route union type.

With our imports set, let’s update the Model and initialModel, which previously only contained recordList:

-- HopExample.App.Model

type alias Model =
  { recordList : HopExample.RecordList.Model.Model
  , route : Route
  , location : Location
  }

initialModel : Model
initialModel =
  { recordList = HopExample.RecordList.Model.initialModel
  , route = LoadingRoute
  , location = newLocation
  }

StartApp will use route and location from initialModel to set the initial state of the router.

View Changes

Next up is the view.

Our view is arguably the most straightforward set of changes; we’ll need to introduce a case to check on the Route union type and make decisions about what to render based on the outcome.

First, the imports:

-- HopExample.App.View

import HopExample.Router exposing (Route(..))

All we’ll need is the Route type and its corresponding data constructors, since we’ll be referencing each by name.

Let’s take a look at the view and pageContent functions, which previously always rendered a list of records:

-- HopExample.App.View

view : Signal.Address Action -> Model -> Html
view address model =
  section
    []
    [ pageHeader
    , pageContent address model
    , pageFooter
    ]

pageContent : Signal.Address Action -> Model -> Html
pageContent address model =
  case model.route of
    HomeRoute ->
      HopExample.RecordList.View.view model.recordList

    LoadingRoute ->
      h3 [] [ text "Loading..." ]

    NotFoundRoute ->
      h3 [] [ text "Page Not Found" ]

Instead of always calling HopExample.RecordList.View.view model.recordList, we now pattern match against model.route and modify the Html returned. As Elm forces us to be exhaustive in our pattern-matching (no partial functions are allowed), we now have a clear picture of what our Route looks like:

-- HopExample.Router

type Route
  = HomeRoute
  | LoadingRoute
  | NotFoundRoute

Neat!

Update Changes

Almost there; let’s dig into changes to HopExample.App.Update.

First, the imports:

-- HopExample.App.Update

import Hop.Types exposing (Location)
import HopExample.Router exposing (Route)

Next, we’ll update our Action:

-- HopExample.App.Update

type Action
  = NoOp ()
  | ApplyRoute ( Route, Location )

I’ve added a NoOp () (() can be read as “void” - it’s a placeholder for any type that’s discarded), and ApplyRoute ( Route, Location ). ApplyRoute is the name recommended by Hop; the only real requirement is that it’s used to handle the signal provided by the router (Signal ( Route, Location )).

Finally, let’s wire up the HopExample.App.Update.update function:

-- HopExample.App.Update

update : Action -> Model -> ( Model, Effects Action )
update action model =
  case action of
    NoOp () ->
      ( model, Effects.none )

    ApplyRoute ( route, location ) ->
      ( { model | route = route, location = location }, Effects.none )

We can see that ApplyRoute is being used to return a model with new state, namely route and location.

Update Main.elm

Almost there! Let’s wire together the router’s Signal ( Route, Location ) into our inputs : List ( Signal Action ). First, our imports:

-- Main

import HopExample.Router exposing (router)

-- old version
-- import HopExample.App.Update exposing (Action, init, update)

-- new version
import HopExample.App.Update exposing (Action(ApplyRoute), init, update)

We were already importing Action, but we also want the ApplyRoute data constructor.

Next, let’s use Signal.map to convert the router‘s Signal ( Route, Location ) to Signal Action:

hopRouteSignal : Signal Action
hopRouteSignal =
  Signal.map ApplyRoute router.signal

Don’t forget to include this new signal:

inputs : List (Signal Action)
inputs =
  [ hopRouteSignal ]

Finally, let’s wire up the outbound port Hop gives us:

port routeRunTask : Task () ()
port routeRunTask =
  router.run

Task might look familiar, especially if you’ve seen Elm’s Result or Haskell’s Either. It’s a binary type constructor with a failure type and success type. For this port, we’re using the aforementioned void to represent that we can disregard both the success and failure types entirely in this function.

Routing Everything Together

With this groundwork in place, let’s build out HopExample.Router. Recall that we’ve already identified what our Route union type will look like:

-- HopExample.Router

module HopExample.Router (Route(..)) where

type Route
  = HomeRoute
  | LoadingRoute
  | NotFoundRoute

Next, in Main.elm, we saw reference to a router function; let’s make a few changes to expose it:

-- HopExample.Router

module HopExample.Router (Route(..), router) where

import Hop
import Hop.Matchers exposing (match1)
import Hop.Types exposing (Router, PathMatcher)

type Route
  = HomeRoute
  | LoadingRoute
  | NotFoundRoute

router : Router Route
router =
  Hop.new
    { hash = False
    , basePath = "/"
    , matchers = matchers
    , notFound = NotFoundRoute
    }

matchers : List (PathMatcher Route)
matchers =
  [ match1 HomeRoute ""
  ]

Here, we configure our router:

  • hash = False configures the router to use push state versus hash state
  • basePath = "/" configures the application to run at the site root
  • matchers = matchers configures the list of routes we’ll match on
  • notFound = NotFoundRoute configures the data type when the router can’t determine the route

Running the Application

One thing to note: if you’re using a server to run the application, ensure the server doesn’t also have a router handling the URLs used by the Elm application. In most cases, you’ll want to configure a catch-all route so anything matching the basePath in the router is sent to the correct page.

With this in place, we can determine what to render to a user based on the route.

Elm Effects for Navigation

We already covered that managing browser push state is handled by an outbound port; how do we trigger that effect, though?

First, let’s expose HopExample.Router.navigateTo (we’d already made Route available):

-- HopExample.App.Update

import HopExample.Router exposing (Route, navigateTo)

As well as a new Action type:

-- HopExample.App.Update

type Action
  = NoOp ()
  | ApplyRoute ( Route, Location )
  | NavigateTo String

And pattern-match to cover the behavior:

-- HopExample.App.Update

update : Action -> Model -> ( Model, Effects Action )
update action model =
  case action of
    NoOp () ->
      ( model, Effects.none )

    ApplyRoute ( route, location ) ->
      ( { model | route = route, location = location }, Effects.none )

    NavigateTo path ->
      ( model, Effects.map NoOp (navigateTo path) )

Now, if we send the action NavigateTo "/my/path/name" to a Signal.Address Action address, our navigateTo function will trigger an appropriate effect and we can trust that we’ll be taken to the correct URL.

Let’s define navigateTo, which really just leverages Hop.Navigate.navigateTo but uses our configuration.

-- HopExample.Router

navigateTo : String -> Effects.Effects ()
navigateTo =
  Hop.Navigate.navigateTo routerConfig

router : Router Route
router =
  Hop.new routerConfig

routerConfig : Config Route
routerConfig =
  { hash = False
  , basePath = "/"
  , matchers = matchers
  , notFound = NotFoundRoute
  }

We’ve moved a few things around here. First, we’ve extracted routerConfig to its own function, since it’s now used by both router and navigateTo. Second, we’ve had to update what we’re importing (namely, import Effects and adding Config to the list of types from Hop.Types).

From a view, we can now do:

exampleView : Signal.Address Action -> Model -> Html
exampleView address model =
  button
    [ onClick address (NavigateTo "/my/path") ]
    [ text "Go somewhere" ]

If you’ve built larger applications in Elm, however, you may recognize a few warning signs:

  1. The only way to trigger a route change with this setup is to pass around an address to send actions to. For deeply nested components, this may be unwieldy because of signal forwarding or circular dependencies.
  2. For large architectures with multiple namespaces, effect management (calling navigateTo) is spread across each HopExample.*.Update.update.
  3. Managing path generation across files may introduce churn if paths change, and there’s no canonical place to identify how paths are generated.
  4. This doesn’t work with traditional anchor tags out of the box, since browsers handle them with default behavior.

Elm Mailboxes to the Rescue

Earlier, we saw in Main.elm that we could take a Signal ( Route, Location ) from our router and turn it into a Signal Action with Signal.map. What if we could do the same thing with NavigateTo?

Let’s look at the type signature for Signal.map:

Signal.map : (a -> result) -> Signal a -> Signal result

So, we apply map to a function of a to result and then to a Signal a, and get a Signal result. Working backwards, let’s imagine we want to add a new signal to Main.elm that we can add to our inputs : List (Signal Action). With that, we know our new navigations signal needs to be of type Signal Action:

navigations : Signal Action
navigations = -- what goes here?

Let’s look at NavigateTo. Remember, NavigateTo is a unary data constructor - that is, it needs to be applied to one argument (in this case, a string) to return an Action:

NavigateTo : (String -> Action)

Take a look at the type signature for Signal.map above. We know we want to get to Signal Action based on our definition of navigations, so we can start fleshing out the details:

navigations : Signal Action
navigations =
  Signal.map NavigateTo functionThatReturnsSignalofString

We need to make available a functionThatReturnsSignalofString; enter Signal.mailbox, a record with an address to send messages to and a signal of sent messages. Let’s open up HopExample.Router:

-- HopExample.Router

routerMailbox: Signal.Mailbox String
routerMailbox =
  Signal.mailbox ""

Here, we’re declaring a new mailbox with an initial state of "". As mentioned above, routerMailbox exposes two properties: address and signal. With that information in hand, and recognizing that the mailbox is for values of type String, things start to fall into place.

Let’s finish up our work in Main.elm. First, expose routerMailbox from HopExample.Router:

-- Main

import HopExample.Router exposing (router, routerMailbox)

And update inputs and navigations with the final changes:

-- Main

inputs : List (Signal Action)
inputs =
  [ hopRouteSignal, navigations ]

navigations : Signal Action
navigations =
  Signal.map NavigateTo routerMailbox.signal

Next, let’s wrap up the pesky route generation and event handling by defining functions including rootPath : String and linkTo : String -> List Attribute -> List Html -> Html:

-- HopExample.Router

rootPath : String
rootPath =
  "/"

linkTo : String -> List Attribute -> List Html -> Html
linkTo path attrs inner =
  let
    customLinkAttrs =
      [ href path
      , onClick' routerMailbox.address path
      ]
  in
    a (attrs ++ customLinkAttrs) inner

onClick' : Signal.Address a -> a -> Attribute
onClick' addr msg =
  onWithOptions
    "click"
    { defaultOptions | preventDefault = True }
    value
    (\_ -> Signal.message addr msg)

Our onClick' (pronounced “onclick prime”) is a custom onClick that prevents default browser behavior and sends our message (e.g. "/path/to/go/to" to an address (routerMailbox.address). You’ll need to update your imports for the router to compile, since there are now functions generating HTML:

-- HopExample.Router

import Html exposing (Html, Attribute, a)
import Html.Attributes exposing (href)
import Html.Events exposing (onWithOptions, defaultOptions)
import Json.Decode exposing (value)

You’ll also want to export linkTo and any path helpers (e.g. rootPath) and use those in place of traditional a for any internal anchors, since they override default behavior.

Routing Exploration

Finally, let’s touch on exploring what you can do with routes. Within my HopExample, there’s a HopExample.Record.Model.Model with an id, among other properties. Let’s look at the path helper, the route matcher, and how we’d use linkTo:

-- HopExample.Router

import Hop.Matchers exposing (int, match1, match2)
import HopExample.Record.Model

type Route
  = HomeRoute
  | RecordRoute Int
  | LoadingRoute
  | NotFoundRoute

matchers : List (PathMatcher Route)
matchers =
  [ match1 HomeRoute ""
  , match2 RecordRoute "/records/" int
  ]

recordPath : HopExample.Record.Model.Model -> String
recordPath record =
  "/records/" ++ (record.id |> toString)

In a view, you’d then be able to:

-- HopExample.*.View

import HopExample.Router exposing (linkTo, recordPath)

renderRecord : HopExample.Record.Model.Model -> Html
renderRecord model =
  let
    recordHeader =
      model.record ++ " by " ++ model.artist
    recordPublished =
      "Released in " ++ (model.yearReleased |> toString)
  in
    div
      []
      [ h3
          []
          [ linkTo (recordPath record) [] [ text recordHeader ]
          ]
      , p [] [ text recordPublished ]
      ]

I’ll leave handling finding the correct HopExample.Record.Model.Model from the list of records as an exercise for the reader.

Wrapping Up

With Hop configured, and the approach to handling navigation changes across all levels of the app within one function (linkTo), we should be in a place where any subsequent changes to both the routing and behavior is entirely encapsulated.