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:
- Model: The model is responsible for maintaining knowledge of where the user is
- View: The view is responsible for rendering the correct content based on where the user is
- Update: The updater/reducer is responsible for handling navigation changes and updating the model accordingly
- Main.elm:
Main.elm
manages all of our outbound ports; because Hop is interacting with the browser, this data transfer happens over a port - 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 statebasePath = "/"
configures the application to run at the site rootmatchers = matchers
configures the list of routes we’ll match onnotFound = 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:
- 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.
- For large architectures with multiple namespaces, effect management
(calling
navigateTo
) is spread across eachHopExample.*.Update.update
. - Managing path generation across files may introduce churn if paths change, and there’s no canonical place to identify how paths are generated.
- 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.