Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Confused when using StateT Aff as my component's monad #386

Closed
themoritz opened this issue Feb 8, 2017 · 18 comments
Closed

Confused when using StateT Aff as my component's monad #386

themoritz opened this issue Feb 8, 2017 · 18 comments

Comments

@themoritz
Copy link
Contributor

I want to use a state monad to run my components in order to have some global app state: StateT String (Aff (HalogenEffects eff))

But when in eval I do something like

do H.lift $ put "foo"
   x <- H.lift get

x is still the old value. Is there a reason for this that I don't see? I'm using the current halogen master.

@themoritz
Copy link
Contributor Author

Here is a minimal example:

module Main where

import Prelude
import Halogen as H
import Halogen.HTML as HH
import Control.Monad.Aff (Aff)
import Control.Monad.Eff (Eff)
import Control.Monad.Eff.Console (CONSOLE, log)
import Control.Monad.State (StateT, evalStateT, get, put)
import Data.Maybe (Maybe(..))
import Halogen.Aff (awaitBody, runHalogenAff)
import Halogen.Aff.Effects (HalogenEffects)
import Halogen.VDom.Driver (runUI)

type AppEffects = HalogenEffects (console :: CONSOLE)
type App = StateT String (Aff AppEffects)

data Query a = Initialize a

app :: forall i o. H.Component HH.HTML Query i o App
app = H.lifecycleComponent
  { initialState: const unit
  , render: const (HH.text "")
  , eval
  , receiver: const Nothing
  , initializer: Just (H.action Initialize)
  , finalizer: Nothing
  }
  
  where

  eval :: Query ~> H.ComponentDSL Unit Query o App
  eval (Initialize next) = do
    H.lift $ put "Bar"
    val <- H.lift get
    H.liftEff $ log val
    pure next
    
main :: Eff AppEffects Unit
main = runHalogenAff $ do
  body <- awaitBody
  runUI (H.hoist (flip evalStateT "Foo") app) unit body

I would expect that this logs "Bar" to the console, but it logs "Foo".

@natefaubion
Copy link
Collaborator

I'm not exactly sure why it behaves this way, but I think you'll find that the normal StateT wouldn't give you what you wanted anyway. StateT is implemented as pure continuation-passing, which means that there isn't actually a central global state, and it's just threaded from bind to bind. Anytime you mount a new component, or process a new event, you're basically forking a new thread, so there would be no global coordination anyway. You could provide a Free version of it though and useMonadState, while providing an interpreter that persists to a global atom.

@garyb
Copy link
Member

garyb commented Feb 9, 2017

Another option would be to use ReaderT instead and store the state in a Ref perhaps. It is counterintuitive this doesn't work, but I think what Nathan says is probably right, I've not had a chance to dig into yet.

@themoritz
Copy link
Contributor Author

Ah I see, since HalogenM ... App is interpreted to Aff everytime a component is mounted, I would always "restart" my StateT in that case. So in any case this is not how I would want to model global app state. 🙂

But still, I would think in the example it should behave differently because it's all happening in one eval. It's basically the same situation as in the following code, so the result should be the same if HalogenM obeys the MonadTrans laws:

flip evalStateT "Foo" $ runExceptT do
  lift $ put "Bar"
  val <- lift get
  lift $ lift $ log val

@natefaubion
Copy link
Collaborator

But still, I would think in the example it should behave differently because it's all happening in one eval. It's basically the same situation as in the following code, so the result should be the same if HalogenM obeys the MonadTrans laws:

I would expect it to behave properly in a single thread as well.

@garyb
Copy link
Member

garyb commented Feb 9, 2017

Yeah, that's a good point. 😕 If it turns out there isn't a solvable bug here I guess I'll have to remove the MonadTrans instance.

@natefaubion
Copy link
Collaborator

Just an FYI

  eval :: Query ~> H.ComponentDSL Unit Query o App
  eval (Initialize next) = do
    H.lift $ put "Bar"
    val1 <- H.lift get
    val2 <- H.lift (put "Bar" *> get)
    H.liftEff $ log val1
    H.liftEff $ log val2
    pure next

Logs "Foo" and then "Bar", which indeed breaks the transformer laws. So we either need to figure out how to make it lawful, or drop the MonadTrans instance. I think we'd all prefer the former 😆

@natefaubion
Copy link
Collaborator

And this is clearly because it's not possible for Free to be a transformer. The Lift constructor in HalogenM only embeds an effect, rather than interleaving it. We'd have to switch to FreeT to get a lawful transformer instance.

@cryogenian
Copy link
Contributor

👍 for removing MonadTrans

@garyb
Copy link
Member

garyb commented Feb 16, 2017

I'm trying out a FreeT based HalogenM first... it might not be too bad.

@garyb
Copy link
Member

garyb commented Feb 16, 2017

@themoritz we figured out what's wrong here at least. It doesn't matter whether the instance is law abiding or not in this case; the problem is interpreting the StateT in the wrong place. Because we only allow m ~ Aff in runUI, there's no choice but to use hoist to interpret the StateT, but that means every individual lift action will be re-interpreted from the starting state.

What needs to happen really is for runUI to return in MonadAff _ m instead, so you can runStateT on what it produces, rather than transforming the component's m type before going into runUI. Attempting to make that work has exposed some difficulties however. Plus, doing that will make this example impossible, as one of the required constraints by runUI will be Parallel for m, which there is no StateT instance for 😄.

I'm torn as to what to do now, as this example has illustrated a pretty severe limitation in the provided runUI, but on the other hand, making it work with MonadAff will potentially have some real drawbacks too. (One of which being, in my first attempt, you need to provide an m ~> Eff to runUI, which will suffer from exactly the same problem that this example! There may be a way around it, but I'm not sure).

@natefaubion
Copy link
Collaborator

Where do you think that leaves us with the MonadTrans instance? You could say it technically doesn't violate the laws, because you can't call runUI in such a way that would invoke expected transformer behavior? It's a little unexpected, though convenient 😆

@garyb
Copy link
Member

garyb commented Feb 17, 2017

I've not given up on the proper fix yet, now I've had some more time to ruminate on it I have a few ideas of things to try to make the implementation work :)

@garyb
Copy link
Member

garyb commented Feb 17, 2017

Although we'll still need to figure something out to stop parSequence_ exploding...

@mpodlasin
Copy link

Hi. Any progress on this one?

As far as I understand this makes providing global state to components difficult? Because of type strictness passing around large amounts of data through component inputs or changing where some state is stored requires quite a lot of work and very quickly becomes tiring in halogen. Any know remedies for that?

@natefaubion
Copy link
Collaborator

@mpodlasin StateT is not a solution to global state, as I stated above. It's possible to use a ReaderT pattern to accomplish this. This can be done via Free, but it's not necessary.

type AppEnv =
  { state :: Ref SomeGlobalState
  }

newtype AppM eff a = AppM (ReaderT AppEnv (Aff eff) a)

runAppM :: forall eff. AppEnV -> AppM eff ~> Aff eff
runAppM env (AppM app) = runReaderT env app

-- derive all the instances
derive newtype instance ....

getState :: forall eff. AppM (ref :: REF | eff) SomeGlobalState
getState = AppM $ ReaderT \r -> readRef r.state

setState :: forall eff. SomeGlobalState -> AppM (ref :: REF | eff) Unit
setState s = AppM $ ReaderT \r -> writeRef r.state s

And use H.hoist with runAppM.

@mpodlasin
Copy link

@natefaubion Thanks a lot! I will try that approach.

@garyb
Copy link
Member

garyb commented Jun 30, 2019

I'm going to close this as the specific issue was solved. I was leaving it open with the intention that one day driver might return a constrained m rather than Aff specifically, but the performance penalty of that will be pretty bad due to the dictionary overhead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants