-
Notifications
You must be signed in to change notification settings - Fork 40
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
Support for jsx3 with hooks #45
Conversation
Terrific, and it's backward compatible, too! Thanks |
No problem! I also want to add some tests to cover the |
I added tests for |
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.
Thanks for working on this! The implementation seems much more complicated than I expected, which makes me a bit nervous. Could very well be that I haven't thought of the edge cases this PR covers fully though. Left some inline comments with more questions!
.gitignore
Outdated
@@ -7,3 +7,5 @@ | |||
npm-debug.log | |||
/_build/ | |||
package-lock.json | |||
*.bs.js |
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.
I'm not sure it makes sense to ignore the generated output. Can be useful to see it in code reviews and for verification.
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.
Agree! Fixed.
}; | ||
}; | ||
|
||
let useSelector = selector => { |
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.
This function seems massively complex and I'm not really sure why. As far as I can tell we just want to memoize selector+storeFromContext and get the resulting view of state?
Would something like this work?
Ignore all of this. I wasn't thinking things through :)
src/reductiveContext.re
Outdated
React.Ref.setCurrent(latestSelector, selector); | ||
|
||
let newSelectedState = | ||
selector(Reductive.Store.getState(Config.store)); |
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.
This function uses Config.store
in a couple places - why does it do that instead of using storeFromContext
?
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.
My mistake! Fixed it in the new implementation of useSelector
.
src/reductiveContext.re
Outdated
let latestSelector = React.useRef(selector); | ||
|
||
// selector function might change if using components' props to select state | ||
React.useLayoutEffect1( |
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.
We should really try to avoid using useLayoutEffect
if we can. It's not really intended for this kind of general state management usecase.
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.
The new implementation based on useSubscription
is using useEffect
instead.
src/reductiveContext.rei
Outdated
""; | ||
let make: {. "children": React.element} => React.element; | ||
}; | ||
let useSelector: (Config.state => 'a) => 'a; |
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.
Where possible it'd be great to use more specific type variable names since they show up in editor tooling. Maybe something like 'selectedState
would be appropriate here?
src/reductiveContext.re
Outdated
|
||
let currentStoreState = Reductive.Store.getState(Config.store); | ||
if (React.Ref.current(latestSelector) !== selector) { | ||
selector(currentStoreState); |
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.
I think you'd want to set latestSelectedState
here, no?
src/reductiveContext.re
Outdated
selector(currentStoreState); | ||
} else { | ||
switch (React.Ref.current(latestSelectedState)) { | ||
| None => selector(currentStoreState) |
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.
Ditto^^
@@ -0,0 +1,224 @@ | |||
[@bs.config {jsx: 3}]; |
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.
🔥
Thanks for the tests!
Interesting. I see this code is based off of https://github.com/reduxjs/react-redux/blob/master/src/hooks/useSelector.js which makes a ton of sense - looking into why that code is written that way. |
Okay I think it would be better to base this work off of https://github.com/facebook/react/pull/15022/files as opposed to the existing react-redux stuff. This approach is much more in-line with the future of concurrent mode and I think the resulting code will be much easier to work with as well. You can imagine a memoized version of selector and a subscribe function being passed as inputs into this hook. |
Thanks a lot for the review! I totally agree that the implementation got quite complicated. By basing this work off of |
I was thinking about having a 1:1 translation inline for now and then swapping out once it’s shipped at https://www.npmjs.com/package/use-subscription Open to other strategies though! |
The
I am concerned about two things here: a scenario where the value is only read in one place seems not applicable for a large redux application, where there are usually quite a few components subscribed to the store, and the better long-term solution with
Would you still advice using the implementation from |
NOTE: the code here is 100% untested, just quickly written to show one of the possibilities and continue the discussion. A very rough implementation of let useSelector = (selector) => {
let store = React.useContext(storeContext);
let subscribe =
React.useCallback1(
handler => Reductive.Store.subscribe(store, handler),
[|store|],
);
let getCurrentValue =
React.useCallback1(
() => selector(Reductive.Store.getState(store)),
[|store|],
);
useSubscription(getCurrentValue, subscribe);
}; And a very rough translation of type subscriptionState('a) = {
getCurrentValue: unit => 'a,
subscribe: (unit => unit) => (unit => unit),
value: 'a,
};
// Hook used for safely managing subscriptions in concurrent mode.
//
// In order to avoid removing and re-adding subscriptions each time this hook is called,
// the parameters passed to this hook should be memoized in some way–
// either by wrapping the entire params object with useMemo()
// or by wrapping the individual callbacks with useCallback().
let useSubscription =
(
// (Synchronously) returns the current value of our subscription.
getCurrentValue,
// This function is passed an event handler to attach to the subscription.
// It should return an unsubscribe function that removes the handler.
subscribe,
) => {
// Read the current value from our subscription.
// When this value changes, we'll schedule an update with React.
// It's important to also store the hook params so that we can check for staleness.
// (See the comment in checkForUpdates() below for more info.)
let (state, setState) =
React.useState(() =>
{getCurrentValue, subscribe, value: getCurrentValue()}
);
let valueToReturn = ref(state.value);
// If parameters have changed since our last render, schedule an update with its current value.
if (state.getCurrentValue !== getCurrentValue
|| state.subscribe !== subscribe) {
// If the subscription has been updated, we'll schedule another update with React.
// React will process this update immediately, so the old subscription value won't be committed.
// It is still nice to avoid returning a mismatched value though, so let's override the return value.
valueToReturn := getCurrentValue();
setState(_ => {getCurrentValue, subscribe, value: valueToReturn^});
};
// Display the current value for this hook in React DevTools.
// TODO: useDebugValue does not exist in Reason-React
// React.useDebugValue(valueToReturn^);
// It is important not to subscribe while rendering because this can lead to memory leaks.
// (Learn more at reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects)
// Instead, we wait until the commit phase to attach our handler.
//
// We intentionally use a passive effect (useEffect) rather than a synchronous one (useLayoutEffect)
// so that we don't stretch the commit phase.
// This also has an added benefit when multiple components are subscribed to the same source:
// It allows each of the event handlers to safely schedule work without potentially removing an another handler.
// (Learn more at https://codesandbox.io/s/k0yvr5970o)
React.useEffect2(
() => {
let didUnsubscribe = ref(false);
let checkForUpdates = () =>
// It's possible that this callback will be invoked even after being unsubscribed,
// if it's removed as a result of a subscription event/update.
// In this case, React will log a DEV warning about an update from an unmounted component.
// We can avoid triggering that warning with this check.
if (! didUnsubscribe^) {
setState(prevState =>
// Ignore values from stale sources!
// Since we subscribe an unsubscribe in a passive effect,
// it's possible that this callback will be invoked for a stale (previous) subscription.
// This check avoids scheduling an update for that stale subscription.
if (prevState.getCurrentValue !== getCurrentValue
|| prevState.subscribe !== subscribe) {
prevState;
} else {
// Some subscriptions will auto-invoke the handler, even if the value hasn't changed.
// If the value hasn't changed, no update is needed.
// Return state as-is so React can bail out and avoid an unnecessary render.
let value = getCurrentValue();
if (prevState.value === value) {
prevState;
} else {
{...prevState, value};
};
}
);
};
let unsubscribe = subscribe(checkForUpdates);
// Because we're subscribing in a passive effect,
// it's possible that an update has occurred between render and our effect handler.
// Check for this and schedule an update if work has occurred.
checkForUpdates();
Some(
() => {
didUnsubscribe := true;
unsubscribe();
},
);
},
(getCurrentValue, subscribe),
);
valueToReturn^;
}; |
Should the user be expected to memoize the selector functions passed to I ran @MargaretKrutikova's tests (thank you for writing those btw) with the implementation I pasted above and the test that checks that changing props are handled gracefully fails: https://github.com/reasonml-community/reductive/pull/45/files#diff-81724037f70299d409a468210e14b0b8R139 It fails because the implementation I provided memoizes the function and assumes Because |
Yes, the selector function should be memoized, and when it depends on And you are right about the tests, I tried using |
Yeah, I see. There are too many unnecessary notifications because subscriptions are made to the whole state, but most components care only a small part of it. I'm thinking that the only reason subscriptions make every component get notified is that reads made by selectors (and updates made to the state in reducers) are made through arbitrary code. Functions are opaque, and you don't know what is going on inside. If updating and reading from the state value was instead done in a more restricted way, such that setters and getters are paired, then it would be possible to track such changes and notify only the components that are actually interested in such change. [What comes ahead is a very undeveloped idea, and it may be a stupid one, just trying to think about it in a different way. I haven't tried to implement it because a stupid accident I had two nights ago left me able to use only one hand for the next 5 weeks.] In this example reads and writes are described with values instead of using functions that perform the operations directly. Then functions are used to execute such descriptions and modify the state accordingly. type state = {
counter: int,
content: string,
};
// what comes next can be generated with a ppx rewriter
// see for example https://github.com/Astrocoders/lenses-ppx
// (not sure if I would use that implementation in particular
// but it is good enough for illustration purposes)
type field(_) =
| Counter: field(int)
| Content: field(string);
let get: type value. (state, field(value)) => value =
(state, field) =>
switch (field) {
| Counter => state.counter
// ...
};
let set: type value. (state, field(value), value) => state =
(state, field, value) =>
switch (field) {
| Counter => {...state, counter: value}
// ...
};
let modify: type value. (state, field(value), (value => value)) => state =
(state, field, update) => {
let prev = get(state, field);
let next = update(prev);
set(state, next);
};
// TODO: this would have to keep track of what fields are touched
// so that it can be referenced later to decide who to notify
let modifyAndTrack = modify;
let reducer = (state, action) =>
switch (action) {
| Increment => modifyAndTrack(state, Counter, add1)
| Decrement => modifyAndTrack(state, Counter, sub1)
// ...
}; Components would read values like this: [@react.component]
let make = () => {
// useSelector would probably use a combination of useSubscription
// and useState internally.
let counter = useSelector(Counter);
// ...
}; The above example is a huge simplification (and the machinery not implemented), ways to compose selectors are needed too (for example, getting an array field, and then getting index N). An example using composition and depending on props would be something like this: [@react.component]
let make = (~userID) => {
// lets say Users get an assoc list of string -> user
// from the state value
let name = useChainedSelectors3(Users, AssocIndex(userID), UserName);
<div>{name}</div>;
}; Subscriptions would be made to specific selector paths instead of the whole store. Does this make sense? |
Alternative encoding for fields using records that may work better. These include paths and compose easily: type field('state, 'value) = {
get: 'state => 'value,
set: ('value, 'state) => 'state,
path: string,
};
let getField = (t, field) => field.get(t);
let setField = (value, t, field) => field.set(value, t);
let modifyField = (field, f, t) => field.set(f(field.get(t)), t);
let composeFields = (f1, f2) => {
let get = t => t |> f1.get |> f2.get;
let set = value => modifyField(f1, f2.set(value));
let path = f1.path ++ "/" ++ f2.path;
{get, set, path};
};
// Example field instance for "counter"
let counter = {
get: state => state.counter,
set: (counter, state) => {...state, counter},
path: "counter",
};
// Array indexing example
let makeArrayIndexField = idx => {
let path = String.of_int(idx);
let get = arr => arr[idx];
let set = (value, arr) => {
let newArr = Belt.Array.copy(arr);
newArr[idx] = value;
newArr;
};
{ get, set, path };
}; One advantage of this encoding is that supporting custom field updaters and readers is easier, because the path is explicit and the list of fields is not limited to an enum. Things are probably easier on the typing department too when handling subscriptions. |
Here is an ugly proof of concept for what I suggested on my previous comments (Index.re is the important part): https://gist.github.com/tizoc/1cae2f1ea466d1e290b04bc35b7c085e There are a few calls to It is probably not the right direction for reductive, all the fields stuff would grow the library considerably, but in general the approach seems to be viable. If anyone is interested let me know and I will setup a small runnable demo in a repository (that gist only contains what is relevant for the implementation and is missing all the boilerplate). Edit: Better example here |
Use useSubscription hook for subscribing to store
I have updated the implementation to be based on The only difference in usage of Would you be able to check the PR once again, @rickyvetter? Appreciate any further feedback! |
I've put your latest code into use with JSX 3 components and it's working splendidly. Thank you so much for taking on this important task. |
Hej, just a drive-by bump as this is a great foundation to make reductive usable with hooks. Is there something blocking this PR from going through still, is additional help in the form of testing needed? Happy to lend a hand wherever. /cc @rickyvetter @MargaretKrutikova Thanks for putting this together, this has been invaluable to us to put together our ReasonReact application. |
Since the official |
<3 thanks for all of your work on this and apologies for being crazy slow on the response here. This looks great! |
This PR addresses #44 and adds support for using
React.Context
with store and components written in JSX3 with hooks.What this PR does:
React.Context
and exposesProvider
that uses store as value,useSelector
hook for querying state anduseDispatch
for accessingdispatch
,JSX3
, and keeps one example withJSX2
for backwards compatibility,bs-platform
andreact
dependencies and movesreason-react
fromdependencies
topeerDependencies
.