-
Notifications
You must be signed in to change notification settings - Fork 349
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
Use 'union trick' to model hook dep arrays #574
Conversation
Using BuckleScript 7.1.0's [union type trick](https://reasonml.org/blog/union-types-in-bucklescript), we can now model hook dependency arrays as (almost) heterogeneous just like in JavaScript. We now need only the following two bindings: - useEffect to model not passing in a dependency array - useEffectN to model passing in an array of zero or more dependencies The same technique applies to the rest of the hook bindings, and the unneeded bindings `useFoo0` to `useFoo7` are now deprecated.
@@ -186,41 +186,59 @@ external useReducerWithMapState: | |||
('state, 'action => unit) = | |||
"useReducer"; | |||
|
|||
/** A hook dependency. */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be clear: we don't actually need this dep
type in the ReasonReact binding. All we need is the hooks that take dependencies to take an array('a)
or array(someType)
. The point is that it's an array of a fixed type, where the array would be empty e.g. [||]
, contain a single element e.g. [|somDep|]
, or could contain elements wrapped in some unboxed GADT like dep
. I just chose to make it array(dep)
to provide an out-of-the-box solution and lock it in. If e.g. BuckleScript shipped with this unboxed GADT e.g. [@unboxed] type any = Any(_): any
, we could use that.
The advantage is that it removes the separate functions for all the tuple sizes. The disadvantage is that we have to wrap each dependency in a GATD. I am not sure if this is a net gain because:
If the |
@jfrolich about the GADT–you have a point. It's actually overkill. All we really need is a function // React.re
/** A React hook dependency. */
type dep;
external dep: _ => dep = "%identity";
[@bs.module "react"]
external useEffectN: (..., array(dep)) => ... = "useEffect"; Now, no need for advanced type-level features. React already uses the About the familiarity with different APIs–I think yes and no. Some bindings are in that style but I think it usually irks people. And judging by the Discord conversation that triggered this PR, people coming to Reason may really question the lack of ergonomics of having N different functions where they needed only one in JavaScript. But on the other hand they may also question the ergonomics of having to wrap the dependencies. So maybe it balances out. |
Ah cool, yes I think a dep function would be better for sure! This removes my concern because now we hide the more advanced implementation to the user! And familiarity not a problem anymore because this pattern is similar in React itself (casting to (With this the unboxed type is not even necessary anymore right?) |
Yes indeed, now the |
It's simpler and more familiar (already used in the ReasonReact codebase).
is this really better? key stroke wise we actually gained a alot |
I think for me the main benefit is not having to remember the rules of how to map the various bindings into the way they'll behave in the JavaScript. Current situation:
Proposed situation:
|
I was thinking the other day that i don't mind giving up type safety here and let me pass 'a to the dep array argument (i would just use a tuple always) |
For me the main issues with the current implementation are
That seems "cleaner" than the GADT, in that you don't need the 1..N suffixes/GADT constructs.
Edit: There is indeed type safety loss, namely that the length of the supplied array could change between calls. This makes me realize that a tuple-based solution is indeed the most canonical one with respect to the type expected by useEffect. So I think finally a GADT based solution could be "better". |
What about using a diff list? Probably overkill too, but for the sake of bikeshedding ^^ |
Can you elaborate? |
Possible @tsnobip. Question is if you want to incur the runtime conversion cost, which may or may not be a real factor. I'm not sure. Here's a quick attempt @jfrolich: type dep;
/** [dep(value)] safely type-casts any [value] into a React hook dependency. */
external dep: _ => dep = "%identity";
[@bs.module "react"]
external _useEffectN:
([@bs.uncurry] (unit => option(unit => unit)), array(dep)) => unit =
"useEffect";
module Deps = {
type hlist(_) =
| []: hlist('a)
| ::(('a, hlist('b))): hlist('a);
// Fold the tuple (nested array representation) into a single dimensional array. (naive approach. tailrec in real world)
let rec array_of_hlist: type a. hlist(a) => array(dep) =
fun
| [] => [||]
| [hd, ...tail] => Array.append([|hd->dep|], array_of_hlist(tail));
};
let useEffect = (effects, deps: Deps.hlist('deps)) =>
_useEffectN(effects, deps->Deps.array_of_hlist);
// Usage:
useEffect(
() => {
Js.log(["bob", true, 123]->Deps.array_of_hlist);
// Outputs: ["bob", true, 123]
None;
},
["bob", true, 123], // surprising to me, is that `Deps.[]` is not required here in the scope! TIL
); |
Thanks for the example @cem2ran, I was trying to write one but the reasonml playground doesn't like GADTs on lists, I don't know why. @jfrolich that example is indeed what I meant. I think it looks pretty neat especially since you don't have to open the diff list module to use it. The additional runtime cost is O(n) with n being very small anyway, so I think it's quite acceptable. |
Is there a way to make Bucklescript transform the recursive function calls into a loop? Or |
tail rec function are already transformed in loop. Last bucklescript don't seem to handle inline attribute or don't use %private for inline the function calls.
generate:
Even with map defined as local function don't help the inlining :( |
yes I guess you could write array_of_hlist this way to make sure it's O(n) : external toDepList : hlist(_) => list(dep) = "%identity";
let array_of_hlist: type a. hlist(a) => array(dep) =
hlist => hlist->toDepList->Belt.List.toArray; |
@tsnobip that wouldn't work due to the underlying representation of the hlist being nested tuples (nested arrays in JS). Maybe this is okay w.r.t React Hooks dependencies? and if so you could just use the "%identity" trick to pass along the dependencies "unflattened". I don't think you can get around the O(n) if there's conversion involved, which again is probably not worth discussing when N < 10 in most cases. Maybe the JS engine has a perf trick to flatten nested arrays, but I think this is as good as it gets unless you're willing to do PPX stuff. |
@cem2ran , sorry I didn't pay attention enough, the diff list implementation I was thinking about is this: type dep;
module DiffList = {
type t('ty, 'v) =
| []: t('v, 'v)
| ::('a, t('ty, 'v)): t('a => 'ty, 'v);
external toDepList : t(_) => list(dep) = "%identity";
let toArray: type a b. t(a, b) => array(dep) =
l => l->toDepList->Belt.List.toArray;
}; With this implementation you wouldn't have nested tuples. |
@tsnobip 👍 I like this encoding better, but fundamentally you're still doing a conversion behind the scenes. Updated impl: type dep;
/** [dep(value)] safely type-casts any [value] into a React hook dependency. */
external dep: _ => dep = "%identity";
[@bs.module "react"]
external useEffectN:
([@bs.uncurry] (unit => option(unit => unit)), array(dep)) => unit =
"useEffect";
module DiffList = {
type t('ty, 'v) =
| []: t('v, 'v)
| ::('a, t('ty, 'v)): t('a => 'ty, 'v);
external toDepList: t(_) => list(dep) = "%identity";
let toArray: type a b. t(a, b) => array(dep) =
l => l->toDepList->Belt.List.toArray;
};
let useEffect = (effects, deps) =>
useEffectN(effects, deps->DiffList.toArray);
// Usage:
useEffect(
() => {
Js.log(["bob", true, 123]->DiffList.toArray);
None;
},
["bob", true, 123],
); |
oh sure, you still need to flatten it to an array, but with a single-digit n, it's far from being a perf issue I guess! I like how it allows to have exactly the same API as JS. |
Edit: React does physical equality check of array elements so disregard comment above. |
Thinking out loud: What if we had multiple /* React.re */
type dep;
external dep2: (('a, 'b)) => dep = "%identity";
external dep3: (('a, 'b, 'c)) => dep = "%identity";
/* etc... */
/* App.re */
React.useEffectN(
callback,
(a, b, c)->React.dep3
); It's basically the same principle as what we currently do, but it moves the arity from the Also, would it make sense to rename We could possibly even shrink /* React.re */
type dep;
external dep: _ => dep = "%identity";
let noDeps = dep(None);
/* App.re */
React.useEffect(
callbackForEveryRender,
React.noDeps
); I haven't tested that, but in JS |
I tried to keep the changes in the existing functions as minimal as possible, but if renaming the functions more directly, I would probably prefer:
|
yes that would make it much clearer than useEffect and useEffect0 (!?) that have the same signature, almost the same name and opposite effects |
Wow lots of discussion here - thanks for all of the input! After reading through discord and here, the main takeaways that I'm seeing are:
Something that hasn't been mentioned so far is that hooks deps is not a good api for Reason. We cannot model the rules correctly and we can likely do much much better. If we introduce a runtime dependency then I think it has to be objectively worth the cost. In my mind there are two ways a proposal can prove that it's worth a runtime and a large breaking change:
That said, I agree that it's unclear exactly how confusing/clear any of these proposals would be to newcomers. It is possible that everyone loves DiffList and there is no confusion whatsoever. I think one approach for getting feedback might be to publish reason-react@rfcs (name pending) which could add/remove apis without fear of breaking changes or mistakes. It could ship RR itself along with a very small demo app and we could point to it as a way for newcomers to contribute. "We'd love feedback on rfcs#a" or something like that? As I type this up it sounds complex and a process that might itself confuse newcomers. But I've already come this far, so submitting anyway. (a) - |
@rickyvetter Thanks for summary. Not having to track dependencies manually sounds absolutely like the right way to go! Great idea with RFCs too. |
I have some input/questions about modelling the 'rules of hooks' in Reason, but I'll put those in #465 ... TL;DR: I'm sure people have already thought about this, but what is preventing us from modelling a series of hook calls as monadic binds. I do like the idea of drafting up alternative implementations and A/B testing them. |
This boils down to deciding between matching idiomatic JS API in Reason API or output. I don't really have any issues with the current API either. Nested JS arrays seem to work fine as argument to On the other hand, @johnridesabike's proposal ( Of course, both approaches could be allowed as below, giving users the choice that better suits their taste. module DiffList = {
type t('ty, 'v) =
| []: t('v, 'v)
| ::('a, t('ty, 'v)): t('a => 'ty, 'v);
};
type deps('a) = DiffList.t('a, unit);
external dep2: (('a, 'b)) => deps(_) = "%identity";
external dep3: (('a, 'b, 'c)) => deps(_) = "%identity";
[@bs.module "react"]
external useEffectN:
([@bs.uncurry] (unit => option(unit => unit)), deps(_)) => unit =
"useEffect"; No need to include |
@sgny No see the code when clicking the third button the effect shouldn't be executed but it is. |
@Et7f3 Thanks for the clear example, I realize my test was incomplete. |
If we want to start adding extra runtime to the hooks, would it make sense to namespace them separately under I think adding extra runtime to hooks can be justified if the runtime increases safety. The problem with hooks currently isn't just that the API is awkward, it's that they're not safe. ReactJS sort-of-solves the safety problem with custom ESLint rules. Reason seems like it's in a great position to enforce correct usage at compile-time in ways that wouldn't be possible in JS. |
@johnridesabike this is a very good idea imho, use the |
If we use the non-zero cost approch @johnridesabike we could use the method of brisk reconciler (hook take and return hlist) to ensure type safety. It also has a ppx support |
Hey folks. Thanks again for all of the input. I've thought about this a lot and right now isn't the time to make these changes. I think right now I want to prioritize getting to the goal of 1. matching the React api as closely as possible while eliminating runtime and 2. deprecation of any existing apis that don't fit that model. useEffect1-7 fit this model well as a 0-runtime solution that only uses very simple Reason concepts. It will serve until that release certainly. Once we get to that point (tentatively referred to as 1.0), I think this discussion should be re-opened. Maybe supporting runtime-dependent features that provide extra coverage is beneficial, but I don't think they should come as part as 1.0. In the mean time, I would be excited to see experiments and to hear results of using any of these strategies (or of strategies for eliminating the tracking entirely). If you are interested in trying out some of the above and are in a place where you can assume the risk of adopting non-mainstream hooks bindings, please report back. We'll see you all again in a couple months :) |
Using BuckleScript 7.1.0's union type
trick, we can
now model hook dependency arrays as (almost) heterogeneous just like in
JavaScript. We now need only the following two bindings:
The same technique applies to the rest of the hook bindings, and the
unneeded bindings
useFoo0
touseFoo7
are now deprecated.