This entry will be pretty similar to my previous purescript-react-basic-hooks routing entry. Where I go over how routing works within the framework. This time it will be be using the halogen UI framework.
Thanks to the halogen realworld example it was easy to figure out how to do routing in halogen.
Here’s my understanding of it. I’ll and try and extract the routing part of the example and focus on that in this entry.
Router
First off, the router component. This component is the parent component. It’s
the component that will decide what to render based on the route that’s being
queried to it. The render
function case
matches on
the Route
then renders the component.
module Component.Router where
import Prelude
import Data.Maybe ( Maybe(..), fromMaybe )
import Data.Either ( hush )
-- Internal Page
import Page.Home as Home
import Page.About as About
-- Internal Service
import Service.Route
import Service.Navigate
-- Web
import Web.Event.Event ( preventDefault )
import Web.UIEvent.MouseEvent ( toEvent, MouseEvent )
-- Effect
import Effect.Class ( class MonadEffect, liftEffect )
-- Routing
import Routing.Duplex
import Routing.Hash
-- Halogen
import Halogen as H
import Halogen.HTML as HH
import Halogen.HTML.Properties as HP
import Halogen.HTML.Events as HE
type State =
route :: Maybe Route
{
}
data Query a = Navigate Route a
data Action
= Initialize
| GoTo Route MouseEvent
type ChildSlots =
home :: Home.Slot Unit
( about :: About.Slot Unit
,
)
component :: forall i o m
. MonadEffect m
=> Navigate m
=> H.Component HH.HTML Query i o m
=
component
H.mkComponent: const { route: Nothing }
{ initialState
, render: H.mkEval $ H.defaultEval
, eval= handleAction
{ handleAction = handleQuery
, handleQuery = Just Initialize
, initialize
}
}
-- Renders a page component depending on which route is matched.
render :: forall m. State -> H.ComponentHTML Action ChildSlots m
= navbar $ case st.route of
render st Nothing -> HH.h1_ [ HH.text "Oh no! That page wasn't found" ]
Just route -> case route of
Home -> HH.slot Home._home unit Home.component unit absurd
About -> HH.slot About._about unit About.component unit absurd
handleAction :: forall o m
. MonadEffect m
=> Navigate m
=> Action
-> H.HalogenM State Action ChildSlots o m Unit
= case _ of
handleAction -- Handles initialization of the route
Initialize -> do
<- hush <<< ( parse routeCodec ) <$> H.liftEffect getHash
initialRoute $ fromMaybe Home initialRoute
navigate -- Handles the consecutive route changes.
GoTo route e -> do
$ preventDefault ( toEvent e )
liftEffect <- H.gets _.route
mRoute /= Just route ) $ navigate route
when ( mRoute
handleQuery :: forall a o m. Query a -> H.HalogenM State Action ChildSlots o m ( Maybe a )
= case _ of
handleQuery -- This is the case that runs every time the brower's hash route changes.
Navigate route a -> do
<- H.gets _.route
mRoute /= Just route ) $
when ( mRoute = Just route }
H.modify_ _ { route pure ( Just a )
navbar :: forall a . HH.HTML a Action -> HH.HTML a Action
=
navbar html
HH.div_
[ HH.ul_
[ HH.li_
[ HH.a"#"
[ HP.href Just <<< GoTo Home )
, HE.onClick (
]"Home" ]
[ HH.text
]
, HH.li_
[ HH.a"#"
[ HP.href Just <<< GoTo About )
, HE.onClick (
]"About" ]
[ HH.text
]
]
, html ]
Page
This is how these pages are defined. These components will
render the text Home and About. In a non-trivial application, these
components will hold several components to form a “page”, but for simplicity I
left them off to just render text inside an h1
.
Route
The key thing here is the Route
sum type. These are all the
possible routes in the application. The rest of the code is for
routing-duplex to help me encode and decode the routes. The
routes can be directly written as strings but I prefer the safety of types.
Plus, the string definition only stays here. So this lessens the fishing
later on if ever there is a bug in the routing.
module Service.Route where
import Prelude
import Data.Generic.Rep ( class Generic )
import Data.Generic.Rep.Show ( genericShow )
-- Routing
import Routing.Duplex ( RouteDuplex', root )
import Routing.Duplex.Generic ( noArgs, sum )
import Routing.Duplex.Generic.Syntax ((/))
-- All possible routes in the application
data Route
= Home
| About
instance genericRoute :: Generic Route _
derive instance eqRoute :: Eq Route
derive instance ordRoute :: Ord Route
derive
instance showRoute :: Show Route where
show = genericShow
routeCodec :: RouteDuplex' Route
= root $ sum
routeCodec "Home": noArgs
{ "About": "about" / noArgs
, }
Navigation
Navigation capbility of this app is defined in a type class. This technique is
known as tagless-final-encoding. It’s a technique
where we define a capability or method without concrete implementations and only
a small set of requirements. In this case, the minimum requirement is it needs to
be a Monad
. Then, an instance for HalogenM
is defined
here to save us from calling lift
everywhere inside the action (e.g
lift $ navigate Home
).
module Service.Navigate where
import Prelude
-- Internal Route
import Service.Route
-- Halogen
import Halogen
class Monad m <= Navigate m where
navigate :: Route -> m Unit
instance navigateHalogenM :: Navigate m => Navigate ( HalogenM state action slots msg m ) where
= lift <<< navigate navigate
AppM
AppM
is a custom application monad that will provide concrete
implementation for the capabilities, in this case it will only implement
Navigation
. As you’ll notice this only provides a natural
transformation from AppM
to Aff
. In a typical
application this will wrap the ReaderT pattern. This is also
the technique used in the halogen real world example. I
learned about this pattern here.
module AppM where
import Prelude
-- Internal Service
import Service.Navigate
import Service.Route
-- Effect
import Effect.Class ( class MonadEffect, liftEffect )
-- Aff
import Effect.Aff ( Aff )
import Effect.Aff.Class ( class MonadAff )
-- Routing
import Routing.Hash ( setHash )
import Routing.Duplex ( print )
newtype AppM a = AppM ( Aff a )
runAppM :: AppM ~> Aff
AppM m ) = m
runAppM (
newtype instance functorAppM :: Functor AppM
derive newtype instance applyAppM :: Apply AppM
derive newtype instance applicativeAppM :: Applicative AppM
derive newtype instance bindAppM :: Bind AppM
derive newtype instance monadAppM :: Monad AppM
derive newtype instance monadEffectAppM :: MonadEffect AppM
derive newtype instance monadAffAppM :: MonadAff AppM
derive
instance navigateAppM :: Navigate AppM where
= liftEffect <<< setHash <<< print routeCodec navigate
Main
Finally, the Main
module. This is where the application is ran.
This is also the place where the browser’s route is observed. Whenever the route
changes the callback of matchesWith
will be called. This callback
will query the router component with Navigate
passing the
new
route.
module Main where
import Prelude
import Data.Maybe ( Maybe(..) )
-- Internal
import AppM ( runAppM )
-- Internal Components
import Component.Router as Router
-- Internal Service
import Service.Route
-- Effect
import Effect ( Effect )
import Effect.Class ( liftEffect )
-- Aff
import Effect.Aff ( Aff, launchAff_ )
-- Routing
import Routing.Duplex ( parse )
import Routing.Hash ( matchesWith )
-- Halogen
import Halogen as H
import Halogen.HTML as HH
import Halogen.Aff as HA
import Halogen.VDom.Driver ( runUI )
main :: Effect Unit
= HA.runHalogenAff do
main <- HA.awaitBody
body let
rootComponent :: H.Component HH.HTML Router.Query {} Void Aff
= H.hoist runAppM Router.component
rootComponent
-- Run the application
<- runUI rootComponent {} body
halogenIO
-- Listen to the route changes.
$ liftEffect $ matchesWith ( parse routeCodec ) \mOld new ->
void /= Just new ) do
when ( mOld $ halogenIO.query $ H.tell $ Router.Navigate new
launchAff_ pure unit