JordanMartinez / learn-halogen

Learn purescript-halogen using a bottom-up apporach via this "clone-and-play" repository
188 stars 29 forks source link

Which to cover first? Halogen Hooks or Halogen Components? #78

Open JordanMartinez opened 4 years ago

JordanMartinez commented 4 years ago

While components are what Halogen actually understands, hooks are easier to read and use. I also think hooks don't require a template because they "just work." I'm not sure whether compiler errors are any worse than components.

Anyways, the question is, should this repo cover hooks first, which are likely easier to use, more powerful/flexible than components, and otherwise just better practically everywhere? Or should they be covered after components because that understanding is still the foundation of Halogen?

thomashoneyman commented 4 years ago

I'm glad you're considering this! I'm a little on the fence myself: I think it's potentially a good idea to start off with Hooks in the future, but there's a strong argument starting with components, too.

I think Hooks are an easier introduction to Halogen. I think there's less to learn about (actions, queries, eval, etc.), especially if you wait to introduce child components. It's a more natural progression from "pure HTML function" to "stateful HTML function".

But there's no way around components: they exist in Halogen, all existing resources still focus on components (because Hooks didn't exist), and ultimately a Hooks-based component is a component. Every Halogen developer will eventually have to learn about components to build an app because Halogen is built around them (things like runUI, for example, take a component). I don't know how long you could push back teaching about components, whereas you could feasibly leave Hooks at the end; after all, they're a new addition and they're an external library, not a core Halogen feature.

Still, if Hooks do start to catch on, then it makes sense to introduce them first as the "standard" way to do things. We're not there yet and it probably makes sense to wait for a while for people to ask questions, problems to arise, and so on before switching over. But if, in a month or two, Hooks seem to be catching on and be the better approach for the default case, then this learning repository is a great way to encourage that style in the community. Your material is influential to how new Halogen devs write their code, and I think a change here would definitely impact how many people adopt Hooks.

Compiler errors

As you mentioned compiler errors, I just wanted to note that they aren't worse than regular Halogen components. There are some new possible compiler errors if you use Hooks in the wrong order in the body of the Hook definition, and in that way they could be subjectively "worse" -- but if you use the wrong field in state, or the input type is wrong, or something like that, the error spans will still be in the right place and the error shouldn't be more verbose.

For example:

type State = Int

initialState :: State
initialState = 0

type Input = String

myComponent :: forall q o m. H.Component q Input o m
myComponent = H.component \input -> Hooks.do
  state /\ stateToken <- useState initialState

  Hooks.pure do
    HH.div
      [ ]
      [ HH.text $ show $ input + 1
      , HH.text $ "hello" <> state
      ]

in this component you'll get an error that input + 1 is invalid because your input is a string, and that "hello" <> state is invalid because state is an integer, and the error spans will be in the right place, and the same is true inside effect blocks and so on. While implementing various Hooks I haven't encountered any odd or verbose compiler errors.

JordanMartinez commented 4 years ago

I agree that we should wait until after Hooks have caught on and their tradeoffs are better known.

As for the compiler errors... well... Having tried to implement some hooks myself, I'd say the compiler errors can be painful in very specific circumstances. However, this is similar to Halogen components: if you're new and put things in the wrong spot, it's hard to reason your way through it unless you already know what's causing it due to experience.

I once got an error like this:

Could not unify kind
  Type
with kind
  Type -> Type

What was the issue? It wasn't the useSelect hook function that the IDE was highlighting. Rather, it was my Newtype instance

newtype UseX slots output {- m -} a = UseX --
derive instance newtypeUseX :: Newtype (UseX slots output m a) _

Removing the extra 'm' in the Newtype instance fixed the problem. I spent quite a long time on that.

Another situation. When I use typed holes, useFoo :: HookM slots output m _ _, I sometimes get a "'slots` has escaped its scope and appears in the type, X" error. That can be frustrating as well.

thomashoneyman commented 4 years ago

What was the issue? It wasn't the useSelect hook function that the IDE was highlighting. Rather, it was my Newtype instance

That's a reasonable point, because this is something you might encounter while writing Hooks, though I wouldn't consider it a Hooks-specific compile-time error. Wouldn't you also encounter this if you wrote a newtype for something else and then used it in another function?

Another situation. When I use typed holes, useFoo :: HookM slots output m _ _, I sometimes get a "'slots` has escaped its scope and appears in the type, X" error. That can be frustrating as well.

I'm curious about this one. In practice I think a Hook type like UseX which contains the slot type will be rare (though I could be wrong); none of the custom Hooks I wrote for the library needed any of the public type variables like slots, output, or m in the actual Hook. Then again, since Hooks make writing reusable stateful logic easier, maybe this will be encountered often and I should invest in some way to make this more palatable (with docs, at least).

Fortunately, I believe only the slot type will cause this error. For example, you can wildcard all of the hook types in the Hooks library examples the same way you have done here, and you won't get an error.

This error is likely confusing and frustrating, as you said. Unfortunately, it's an error you can run into in ordinary Halogen code, too, though it'll be encountered more often if Hooks are regularly written with the slot type in the Hook type.

thomashoneyman commented 4 years ago

By the way -- if you continue encountering confusing type errors, I'd love if you shared them with me so I can add them to the Hooks documentation. That way people searching for the type error text can find the solution right away.

JordanMartinez commented 4 years ago

Ok, I'll keep that in mind.

Oh, actually, I think a better way to produce such documentation is to take a normal sensible hook implementation, change it on a simple way and see what kinds of errors arise, document the errors, revert those changes, and loop. That would probably cover most cases in a 15 minute window of time.

JordanMartinez commented 4 years ago

So here's a fun fact. The below hook implementation compiles depending on which type I use. The only difference is this:

-- using this type will compile successfully...
Hooked _ _ _ previousHooks (MyHook previousHooks) _

-- using this type will produce a compiler error...
Hook _ _ _ MyHook _

-- despite them being the same as far as I can tell:
type Hook slots ouput m nextHook a = 
  forall priorHooks. Hooked slots ouput m priorHooks (nextHook priorHooks) a

When I use the Hook type, I get this error:

src/Halogen/Hooks/Extra/Hooks/UseEvent.purs:167:8

  167        (UseRef (Maybe (HookM slots output m Unit))))
              ^^^^^^

  Could not match kind

    Type

  with kind

    Type -> Type

  while checking the kind of MonadEffect m => Hook slots output m (UseRef (Maybe (... -> ...)) (UseRef (Maybe ...)))
                                                { push :: a -> HookM slots output m Unit
                                                , setCallback :: Maybe ... -> HookM slots output m Unit
                                                , unsubscribe :: HookM slots output m Unit
                                                }
  in value declaration useEvent

Code below.

useEvent :: forall slots output m a hooks
   . MonadEffect m
  => Hooked slots output m hooks
      (UseRef (Maybe ( (HookM slots output m Unit -> HookM slots output m Unit)
                     -> a
                     -> HookM slots output m Unit
                     ))
      (UseRef (Maybe (HookM slots output m Unit))
      hooks))
      { push :: a -> HookM slots output m Unit
      , setCallback :: Maybe ((HookM slots output m Unit -> HookM slots output m Unit) -> a -> HookM slots output m Unit) -> HookM slots output m Unit
      , unsubscribe :: HookM slots output m Unit
      }
useEvent = Hooks.do
  _ /\ unsubscribeRef <- useRef Nothing
  _ /\ callbackRef <- useRef Nothing

  let
    push :: a -> HookM slots output m Unit
    push value = do
      mbCallback <- liftEffect $ Ref.read callbackRef
      let
        setupUnsubscribeCallback = \unsubscribe' -> do
          mbUnsubscribe <- liftEffect $ Ref.read unsubscribeRef
          case mbUnsubscribe of
            Nothing -> do
              liftEffect $ Ref.write (Just unsubscribe') unsubscribeRef
            _ -> do
              -- no need to store unsubscriber because
              -- 1. it's already been stored
              -- 2. no one has subscribed to this yet
              pure unit
      for_ mbCallback \callback -> do
        callback setupUnsubscribeCallback value

    setCallback callback =
      liftEffect $ Ref.write callback callbackRef

    unsubscribe = do
      mbUnsubscribe <- liftEffect $ Ref.read unsubscribeRef
      case mbUnsubscribe of
        Just unsubscribe' -> do
          unsubscribe'
          liftEffect $ Ref.write Nothing unsubscribeRef
        _ -> do
          pure unit

  Hooks.pure { push, setCallback, unsubscribe }
thomashoneyman commented 4 years ago
-- using this type will compile successfully...
Hooked _ _ _ previousHooks (MyHook previousHooks) _

-- using this type will produce a compiler error...
Hook _ _ _ MyHook _

-- despite them being the same as far as I can tell:
type Hook slots ouput m nextHook a = 
  forall priorHooks. Hooked slots output m priorHooks (nextHook priorHooks) a

The important part here is that every Hook takes, as its last argument, a type variable representing the next hook. All Hooks are thus of kind Type -> Type.

The entire chain of hooks you put together also needs to end up being of kind Type -> Type, where the innermost Hook takes a type variable hooks as its argument (because it's the very first Hook).

The Hook type synonym will apply this type variable to Hook type you provide it. For example:

-- this compiles
Hook slots output m (UseRef Int)

-- and is the same as this
Hooked slots output m priorHooks (UseRef Int priorHooks)

But if you start building a chain of Hooks you can see that the type variable needs to be applied to the innermost Hook:

-- this doesn't compile
Hook slots output m (UseRef Int (UseRef Int))

-- because it's the same as this
Hooked slots output m priorHooks (UseRef Int (UseRef Int) priorHooks)

-- instead of this, which does compile
Hooked slots output m priorHooks (UseRef Int (UseRef Int priorHooks))

You can see that in the full Hooked type you wrote out above, which does correctly put the hooks type variable in the innermost Hook.

You can solve this by writing a newtype or type synonym:

newtype UseEvent ps o m hooks =
  UseEvent
    (UseRef (Maybe (HookM ps o m Unit -> HookM ps o m Unit -> a -> HookM ps o m Unit))
      (UseRef (Maybe (HookM ps o m Unit)) hooks))

-- now this compiles
Hook slots output m (UseEvent slots output m) ...

This is definitely a confusing error to run into, and I should do a better job of explaining how this works (or potentially find a way to prevent this from occurring). However, if you follow the principle of making a new Hook type to represent your stack of Hooks then you won't run into this issue.

JordanMartinez commented 4 years ago

That explains it. For context, I was using the newtyped version of my code. However, once I made changes to the hook's implementation, the type signatures became outdated and got in the way. When I removed them, I could verify that my code compiles and let type inference infer what their signatures were. So, that was why the hooks weren't being newtyped in the first place. That provides clarity as to how to build one's hook, so thanks for explaining!

JordanMartinez commented 4 years ago

Another issue just posted in Slack:


I've got a mystifying error with Hooks, here's the code:

module Components.BookSearchModal whereimport Prelude
import Data.Tuple.Nested ((/\))
import Halogen.HTML as HH
import Halogen.Hooks as Hookscomponent =
  Hooks.component \_ _ -> do
    x /\ xId <- Hooks.useState 0
    Hooks.pure
      $ HH.div [] []

I'm getting this error about infinite types:

[0] [1/1 InfiniteType] src/Component/BookSearchModal.purs:24:5
[0] 
[0]           v
[0]   24      Hooks.pure
[0]   25        $ HH.div [] []
[0]                          ^
[0]   
[0]   An infinite type was inferred for an expression:
[0]   
[0]     UseState Int t1
[0]   
[0]   while trying to match type t3
[0]     with type UseState Int t1
[0]   while checking that expression (apply pure) ((div []) [])
[0]     has type Hooked t0 t1 (UseState Int t1) t2
[0]   in value declaration component

The issue was using do rather than Hooks.do.