Open JordanMartinez opened 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.
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.
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.
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.
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.
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.
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 }
-- 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.
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!
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
.
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?