Purescript Halogen Routing

Hi guys,

I am trying to implement a router that scales with halogen. Here is the link of the repo https://github.com/Woody88/dev-cheatsheets/tree/routing/src. So my first approach uses output message to push the history state, however, @natefaubion suggested that if I wanted it to scale better an approach like this https://github.com/vladciobanu/purescript-halogen-example/blob/master/src/Control/Monad.purs might be better.

Based on the vladciobanu’s repo, he created a Free Monad (which I am not really familiar with) that allows him to pass use it as the “m” in the halogen components that he creates. With this Monad he can execute a function that he named navigation which will push the new state of the route. In his main he created an Event using purscript-event library which will have a handler that listens to a route change from the component and executes a Query action to actually perform the route change.

Now I was told that I could easily implement it using ReaderT by trying to replicate vladciobanu as much as possible. However, I am not sure how I would execute it in the main because I must initially create a ReaderT which will handle my computation but I dont understand how do I pass it to the halogen and how will I handle event route change when a component executes the navigate function. I still haven’t perfectly grasped Transformer so I think that is why I am having difficult figuring out the approach.

Thanks in advance.

module App.AppMonad where
import Control.Monad.Reader.Trans
import Prelude

import App.Navigation (class NavigateDSL)
import Effect (Effect)
import Effect.Aff (Aff)
import Effect.Class (liftEffect)
import Router (Route)

type AppEnv =
  { navigate :: Route -> Effect Unit }

newtype AppM a = AppM (ReaderT AppEnv Aff a)

derive 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

instance navigateDSLAppM :: NavigateDSL AppM where
  navigate route = AppM do
    env <- ask
    liftEffect $ env.navigate route

I’ve had to implement the same thing just a week ago, so I think I can help :slight_smile:

If you change the underlying Monad for your top-level Halogen component you’ll need to use the hoist function to transform it back into Aff before handing it off to runUI.

For your example you’d need to write:

runAppM :: forall a. AppEnv -> AppM a -> Aff a
runAppM env (AppM m) = runReaderT m env 

and then change your main from:

app <- runUI R.router location.path body


app <- runUI (H.hoist (runAppM appEnv) R.router) location.path body

Now you can change your Router component from:

router :: H.Component HH.HTML Query String Message Aff


router :: H.Component HH.HTML Query String Message AppM

One problem you’ll run into is that you can’t actually construct the AppEnv the way it’s set up just yet. It needs a reference to the driver (you called it app) that is returned from runUI in order to send routing events into the application, but at the same time you need to construct an AppEnv to even get at that app value.

The way I solved it is to change the Env into navigate :: Route -> Ref (Effect Unit), fill it with a dummyRouter that just logs out the Routes its supposed to go to (might help you debug later on if you run into bugs) and then overwrite it with the proper Router as soon as the app started up:

 -- in main
  navigate ← liftEffect (Ref.new dummyRouter)
  let env = AppEnv { navigate }
  body ← awaitBody
  let ui = H.hoist (runAppM env) R.router
  io ← runUI ui unit body
  liftEffect (Ref.write (mkRouter io) router)

where mkRouter might look something like this:

mkRouter ∷ HalogenIO Query Void Aff → Route → Effect Unit
mkRouter io r = do
  pushHash (printRoute r)
  launchAff_ case r of
    Route1 → do
      io.query $ H.action LoadRoute1
    Route2 someData → do
      io.query $ H.action $ LoadRoute2 someData

One more thing I did to make it a little nicer to use this NavigateDSL interface, was to use instance chains to automatically lift it through HalogenM (this allows you to freely mix calls to navigate with H.modify without lifting) and to implement it in terms of MonadAsk which makes it easier to use in a testing scenario (as long as your test monad implements the right MonadAsk you don’t need to write another instance):

class Monad m ⇐ NavigateDSL m where
  navigate ∷ Route → m Unit

instance halogenMNavigateDSL ∷ NavigateDSL m ⇒ NavigateDSL (HalogenM s f ps o m) where
  navigate = HalogenM ∘ liftF ∘ Lift ∘ navigate

else instance mAskNavigateDSL ∷ (MonadAsk { router ∷ Ref (Route → Effect Unit) | e } m, MonadEffect m) ⇒ NavigateDSL m where
  navigate r = do
   router ← liftEffect ∘ Ref.read =<< asks _.router
   liftEffect (router r)

In order to make this work you’ll also need to implement MonadAsk and MonadEffect for your AppM:

import Type.Equality as TE

derive newtype instance monadEffectAppM :: MonadEffect AppM

instance monadAskAppM ∷ TE.TypeEquals e AppEnv ⇒ MonadAsk e AppM where
  ask = AppM (map TE.from ask)

I’m sure this was a lot and I’m not sure if I forgot something, but maybe you can use this as a starting point and let me know if you run into problems.


A few notes on this example:

  • is a unicode alias for <<<.
  • An instance chain is not necessary. With it, it’s no longer possible to write any other implementations. If you were writing a test implementation you’d have to implement it in terms of ReaderT and Effect. This may not be a big deal for applications, but I don’t see what you gain from this as opposed to implementing an instance for AppM directly.

One other note, the NavigateDSL type class is only useful if you write your components generic to this class.

router :: forall m.
  NavigateDSL m =>
  H.Component HH.HTML Query String Message m

If you want to use AppM directly, then you can eschew the type class and just write a navigate function in terms of AppM.

navigate :: Route -> AppM Unit
navigate r = AppM do
  router ← liftEffect <<< Ref.read =<< asks _.router
  liftEffect (router r)

@Woody88 I’ve been working on @cvlad’s original Halogen example repository to convert it to use ReaderT instead of the Free approach, similar to what you’re doing.

You can see what that work looks like here:

It’s still a work in progress, but you can at least see how most of the changes can be made and soon it should be done.