-
Notifications
You must be signed in to change notification settings - Fork 8
API: We should throw an exception when a hook is used outside of a component context #39
Comments
I have been talking a bit with @cristianoc about hooks and how their rules could be encoded in the type system, and he shared with me some time ago an exploration that involved using linear types to track usages of hooks inside the render function: https://gist.github.com/cristianoc/cef37bcfc0446da482da4723dc3319a8 @bryphe I believe Cristiano's solution was targeting the detection of usages of hooks inside conditional statements. But I think it would solve the problem you mention above (usages outside One downside of this approach is in "API costs": the value that contains the linearity of @bryphe I wonder what you think about this, and if it would be an approach worth exploring? An implementation in the type system has some implications, but I think it could bring a better dev experience. The implementation also would be more idiomatic than having these checks implemented with tests + runtime exceptions. I also wonder if, considering the current design of |
Cool, thanks for thinking about this @jchavarri & @cristianoc . The current model we have in this project doesn't sit right with me, just because it's too easy to get wrong and crash. We should be 'using the language' for this.
I really like @cristianoc 's proposal. Preserving the One of the major benefits of hooks, imo, is the ability to compose & reuse them - so it was helpful to see the example in the gist of how that would work.
Agreed! So if we used a model like @cristianoc 's - we'd get better type checking in general, and it would solve this problem (actually, completely sidestep the problem) - with the minor cost of passing a |
I played a bit with the " For example, in this gist the last function calls a different number of times the This makes me wonder if we should explore an approach that combines the ideas from the linear types solution by @cristianoc but changing the API so the hooks return the values inside a callback, in a continuation passing style. Essentially, the idea expressed in https://paulgray.net/an-alternative-design-for-hooks/. This, combined with something like https://github.com/jaredly/let-anything/ could lead to a result like: let render = () => {
let.Hook (count, dispatch) = useReducer(reducer, 0);
<view>
<button title="Decrement" onPress={() => dispatch(Decrement)} />
<text> {"Counter: " ++ str(count)} </text>
<button title="Increment" onPress={() => dispatch(Increment)} />
</view>;
}; This approach would remove the need of passing the |
Ah, good catch! That would make it easy to lose the safety net we constructed around this concept.
Great idea! The issue I had with the proposal in the article was the syntax being more cumbersome to work with (for a JS dev coming from React), and I wasn't sure how to address that. But your idea to use a PPX solves that! I think the the Do you see any downsides with the It's pretty amazing to me that we could validate the situation you mentioned - calling |
This looks like a very interesting direction. @jchavarri would you flesh out what let.Hook would desugar to? |
@bryphe Glad you're on board with the idea! 😄
I think with a type-based solution, there would be no more need for explicit rules anymore... one would just have to follow the type system guidance (but any additional documentation to help understand what the type system is doing always helps 🙂 ).
One downside imo is that it's a heavier approach than the current API. So to alleviate the heavier API we introduce a dependency on the PPX, which is also not ideal as ppxs bring some inherent complexity. But with native support for the continuation passing style ppx in Reason in the horizon (reasonml/reason#2140) I see the risk of that dependency would be kind of mitigated. The other downside is that I think the more complex types make the API less accessible. While this kind of abstractions are quite idiomatic in OCaml and other typed functional languages like Haskell, I think it takes some time for someone coming from JavaScript to understand them because there's no referents to rely upon in the language we come from. Or at least that's been my experience. 😅 It took me some time for these monadic solutions to "click" for me, and I still struggle when I have to design or work with monadic-like types. Using ppxs also obscure the dev experience, as errors can be more cryptic etc. However, I think these challenges can be counter-balanced with great documentation and lots of good examples. I believe that the unlocked power that the type system offers makes it worth it!
@cristianoc Sure! The example above would desugar to: let render = () =>
useReducer(reducer, 0, (count, dispatch) =>
<view>
<button title="Decrement" onPress={() => dispatch(Decrement)} />
<text> {"Counter: " ++ str(count)} </text>
<button title="Increment" onPress={() => dispatch(Increment)} />
</view>
); In terms of types, I'm not sure yet about all the details and would appreciate any insights you might have, but it seems the return value of the components module Hooks = {
type state('a);
type element('t);
let primitive: string => element(unit) = Obj.magic;
let addState:
(~state: 'state, element('t)) => element(('t, state('state))) =
(~state as _, x) => Obj.magic(x);
};
let useState = (state, f) => {
let setState = x => ignore(x == state);
Hooks.addState(~state, f(state, setState));
};
let helloComponent = (~message) =>
useState("Harry", (name, _setName) => {
Hooks.primitive(name ++ ", you have a message: " ++ message);
}); In that case,
The custom hooks, because they're made from core hooks, would also compose the core types accordingly. I think / hope 😄 |
@bryphe Another downside: there is an extra function call that has to be made to call the callback passed to the hook to get the results back. I mention this not only for perf (the cost of a function call can probably be omitted) but mainly for debugging purposes. From Dan Abramov's blog post:
Except for the last sentence –I don't understand exactly what he meant with "indirections" and "complicate"– I can see the points he makes. There was a very interesting convo on Twitter a few months ago about a monadic solution for React hooks that mentioned some of the downsides and upsides of a monadic solution: https://twitter.com/rauchg/status/1057662611641196544 |
I started something in jchavarri@02b8643. I'm struggling with some typing issues, because the callback in So, right now, the compiler fails to type check. I wonder if there's a way to keep the simple type But I can't come up with anything that works. Maybe we actually need to propagate the |
Thanks for the thorough and detailed proposal @jchavarri ! Those twitter conversations on hooks / type safety were really interesting. I suspect that perhaps the monadic / continuation models in JavaScript have a different trade-off profile vs here in Reason - since there are lots of JS environments that couldn't give you type-safety (and no ubiquitous tooling like ppx), it's not worth it. However, since we do have those tools with
Yes, this seems tricky! It seems like the important place for this is the place we actually call the component's rendering function, the
I wonder if there is a composite type we could return here, but then discard the Thinking about a desugared
Perhaps it would make sense for
(Something like that). I believe this would keep us from needing to propagate the I'm not an expert on managing the type system though; not 100% sure this works! I'm optimistic there is a way to express this though.
Hmm, what would this look like? Would we need to add |
Thanks for the help and guidance @bryphe ! I really appreciate it. 🙂 I'm documenting here the progress so it serves to arrange my thoughts, and maybe also helps in case you, @cristianoc or maybe others have ideas on how to keep moving forward, as I'm not sure I have enough knowledge about OCaml and type systems to implement this 😄 . I started following the path you proposed: make
But in that last step is where I find the main show-stopper. We don't want to consolidate all children under the same type, as we want to keep allowing components to render one child with So it seems we might want an approach without tuples. Then I thought about a different strategy which would involve adding an extra variant to the
With So... maybe making To summarize, I think my issue is I don't know how to create a type for hooks that is composable / abstract so we can keep track of the different hooks that are applied in the component I hope this makes some sense! 😄 |
Not sure I'm actually helping at all, I'm learning right along with you 😄 Great write-up and summary as always! I was reading through this blog post again - Diff Lists - trying to get some ideas. I'm still trying to learn GADTs so I hope that everytime I read it maybe more will sink in 😄 It describes an interesting way to construct type-safe, heterogenous lists.
At the top of that blog post - there is an example of creating a heterogenous list which has a hidden type (using an existential). The syntax is like this (in reason):
I suspect we could use a similiar strategy to drop the type variable for the If it does - I wonder then if we could extend that 'diff list' idea in the blog post, and instead of a tuple of My thinking is we could have a
We'd be generalizing the actual component-render as a hook too here. In other words, components would always return a list of hooks - but in the normal-no-hook-case, it would just be a single node of I was playing with this idea and it would certainly have downstream impacts (didn't quite get it compiling, yet) - https://github.com/revery-ui/reason-reactify/tree/bryphe/wip/ideas-for-hooks - it would unfortunately involve cross-cutting changes - every component would need to now render a 'hook', even if it is just wrapping their existing behavior. Then, there'd be a couple cases:
We'd always return this typed, heterogenous linked list. The empty case would just be a single-entry,
The chaining would happen naturally as a result of your proposed continuation model - we'd append a node to the list at each level of the continuation, until we returned the full diff-list of hooks + render. One nice property of this is that (I think) we could get out of the business of having to track global state for effects / hooks. The render function would return this linked list / diff list of 'hooks' from the render function, which conveniently stores the same things that we currently put in our global state management / effect management variables. We could then unpack the resulting linked-list it to get the child components (it'd be a Sorry this is sort of a brain-dump and nothing concrete... just some ideas I had trying out the diff list idea. |
Thanks a ton @bryphe !! Your message is very encouraging because I was doing some work in parallel: https://github.com/revery-ui/reason-reactify/compare/master...jchavarri:starting-hooks?expand=1 And it seems both approaches are quite similar 🎉 as they're both based in GADTs. I'm also learning GADTs, but from what I understand, we can leverage them to keep the abstract types coming from the different hooks (like In the branch and wrappedElement =
| Hook(component, hook('t)): wrappedElement
| Regular(component): wrappedElement which is very similar to the and hook =
| SetState('a): hook
| Render(component): hook (Side note: I think we should rename the type One of the things I'd like to try is to keep the So, a component with hooks would always return @bryphe What do you think about the approach above? Maybe it's over complicating things a bit? I'm not sure where these roads will take us but in general using a GADT seems like a promising path 😄 Will keep investigating! |
Hm... so, after more attempts in the The challenge is that we want the calls to So, let's assume, supposing the above is true, that the @bryphe @cristianoc Does the above make sense? I might keep exploring the functor path, unless you know if there are ways to implement this behavior using first class modules? |
@jchavarri I had the same thought: that with GADTs one can abstract only at the end. |
@jchavarri - this sounds like a very promising approach! Essentially compile-time magic with the type system that has no runtime impact, but validates at compile-time that the hooks are used correctly 👍 And then as @cristianoc mentioned - we can abstract at the end so that it doesn't need to percolate through the rest of the types.
Definitely open to it! Being crisp on the nomenclature (
I'm not coupled in any way to our implementation using first-class modules - so I'm happy to go down the functor route instead. I view what we have with first-class modules just being a stepping-stone to a nicer API, and I think things like the Your PPX prototype showed some interesting possibilities in creating |
@bryphe Thanks for all the feedback! It's really great to know there's room to explore, and that these proposals can be considered and might be ultimately used 🙂 @bryphe @cristianoc I continued exploring / learning more about functors, first-class modules and type unification. After trying a few solutions based in functors, I realized there was no need for them if we used the same signature that we use now, where the I have created a sketch with all the prototyping code and some details about the latest state of the solution: https://sketch.sh/s/3SQyO9p2IXgm0mEBPNiKXW/ Note that that approach is probably too generic: there are 3 types injected in the If you're on board with that solution, I can start trying to implement it in a new PR. I have a couple of thoughts / questions:
module IntComponent = (
val createComponent((render, {useState}, ~one, ~two) =>
render(
useState(3, (state, _setState) => <text>{string_of_int(one + two + state)}</text>),
)
)
); Maybe there could two factory functions: In any case, I think that last one is a smaller change, and I would probably tackle the "continuation-passing" API for hooks first, and we can always figure this out later on. I also wonder how this new param. What do you think? |
I got the continuation-passing style working in #43 🎉 And I'm exploring a ppx in the Very exciting! 😄 |
To go back to this original issue (sorry to derail a bit from it 😅 ) I have been thinking how to prevent usages of hooks outside the render function. I'd like to propose to add the hooks to the callback that is used in the So, instead of: module ComponentWithState = (
val createComponent((render, ~children, ()) =>
render(
() => useState(2, ((s, _setS)) => <aComponent testVal=s />),
~children,
)
)
); We would do: module ComponentWithState = (
val createComponent(({render, useState}, ~children, ()) =>
render(
() => useState(2, ((s, _setS)) => <aComponent testVal=s />),
~children,
)
)
); The first param of the function passed to Upsides
Downsides
@bryphe If you're on board with this, I could start working on this now, so maybe can get added to |
We don't do any checking or handling when a hook is called outside of a
render
function. This is simple to reproduce - just by callinguseState
outside of a component.We should add a test case that exercises this, and validates an appropriate, actionable exception is thrown.
The text was updated successfully, but these errors were encountered: