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
useSubscription hook #15022
useSubscription hook #15022
Conversation
You may ask author about ownership like you did with scheduler package. |
}); | ||
|
||
// If the source has changed since our last render, schedule an update with its current value. | ||
if (state.source !== source) { |
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.
isnt this somewhat a side effect in render phase? i suppose it leads to a predictable result even if a particular render gets replayed, but shouldnt generally this be done inside useEffect?
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.
No. A side effect would be e.g. mutating a variable or calling a callback. This is just telling React to schedule some follow up work.
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 is essentially following the pattern we recommend for derived state:
https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops
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 clarification!
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 know you showed me this earlier, but I can't remember why this part is necessary given that source
is one of the deps in the effect below.
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.
So the thing I'm guarding against here (which maybe my comment doesn't do a good job of clarifying) is this:
- Subscription added to source A
- Component renders with a new source, source B
- Source A emits an update
- Passive effect is invoked, removing subscription from A and adding to B
We need to ignore the update from A that happens after we get a new source (but before our passive effect is fired).
I tried to make this clear with all of the inline comments but maybe I could improve them somehow?
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.
Is didUnsubscribe
not sufficient for that?
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.
No. Because in the case I mentioned above, we haven't unsubscribed yet (because the passive effect wasn't yet fired).
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 always fire pending passive effects right at the beginning of setState
, to prevent this type of scenario
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.
Ah but I guess that would require closing over a mutable variable. Ok now I get it.
Yeah. If we decide to move forward with a package like this for a few derivative hooks, it may be worth reaching out to TJ about the package name. Too soon at the moment though. |
// If the value hasn't changed, no update is needed. | ||
// Return state as-is so React can bail out and avoid an unnecessary render. | ||
const value = getCurrentValue(source); | ||
if (prevState.value === value) { |
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 check is only necessary right after subscribing to a new source, correct? But it looks like you're checking every time the subscription produces a new value.
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 check might be useful in two scenarios:
- Our manual call to
checkForUpdates()
in the passive effect body (a few lines below). - When we attach our subscription (since some sources, like rxjs, will auto-invoke a handler when attached).
I could use another local var (like didUnsubscribe
) to track this but that doesn't seem any better than this check, IMO.
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.
How are 1 and 2 different? I would expect that if you have 1 (the manual call) you don't need 2.
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 sync-check in case we've missed an update (1). We also need to subscribe for updates to be notified of future updates (2). We wouldn't have to sync-check if all subscription sources auto-invoked our subscription callback, but that's not the case. Some do, some don't.
It occurs to me that // Instead of `source` argument
function useStore(store) {
return useSubscription({
source: store,
getCurrentValue() {
return store.getState(),
},
subscribe() {
return store.subscribe(),
}
});
}
// Use deps array, like we do with useEffect and useMemo
function useStore(store) {
return useSubscription(() => {
return {
getCurrentValue() {
return store.getState();
},
subscribe() {
return store.subscribe();
},
};
}, [store]);
}
Aside from consistency between APIs, this also lets you depend on multiple values changing, and you don't need to add an extra |
Hm. Interesting. No, I didn't really consider that variation. We wouldn't have Dan's awesome lint rule to back us up (since this is a derived hook). |
I can add an exception if it's a recommended one. |
@gaearon Is figuring out a heuristic for custom hooks on the roadmap? Seems important. (Although I don't have any ideas.) |
Okay. I'll update this PR with the proposed API change then. |
Updated! |
31af392
to
87f7c31
Compare
Based on the outcome of our chat this morning, I've reverted the dependencies array change in favor of the previous Back to you for review, @acdlite. |
b3e8a0a
to
140cdb8
Compare
Does the first example in the PR post need updating? I got confused for a second. |
Ah, yeah. I updated the Gist but forgot about the PR description example. |
subscribe: callback => {
source.addEventListener("change", handler);
return () => source.removeEventListener("change", handler);
} did you mean subscribe: handler => {
source.addEventListener("change", handler);
return () => source.removeEventListener("change", handler);
} in the PR description? (callback -> handler) |
return {...prevState, value}; | ||
}); | ||
}; | ||
const unsubscribe = subscribe(source, checkForUpdates); |
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.
why do we need to pass source in here? wouldn't it already accessible at the call site?
export function useSelector(selector) {
const store = useContext(ReduxContext);
const subscription = useMemo(
() => ({
source: store,
subscribe: (store, handler) => {
// why do we need the store as argument here?
return store.subscribe(handler);
},
getCurrentValue: () => {
return selector(store.getState());
}
}),
[store]
);
return useSubscription(subscription);
}
140cdb8
to
64f0b44
Compare
I've updated this proposal again with a less redundant API that leans more heavily on |
Thanks for the clarification! I am still not sure I understand it completely .. according to the README it's okay to use the
The same is not true for Is there anything I am missing here? Appreciate your time! |
Just ignore the "change frequently" bit in my previous message. I didn't mean to focus on that aspect so much as the fact that using this approach for Redux would likely cause a lot of the de-optimized, sync re-renders for your app since Redux apps generally have a lot of components listening to the Redux store. (How much you care about this depends on whether you're using concurrent mode in the first place, but it would be sad if that turned out to be the only thing preventing you from using it down the road.) |
@MargaretKrutikova I developed reactive-react-redux which is a drop-in replacement for react-redux and it should be concurrent mode friendly AFAIU. It puts redux state in context and uses subscription. Please see this discussion for details. |
@bvaughn , thanks a lot for the info! There is an ongoing discussion in this PR in @rickyvetter , this might give us some more insight into how to proceed with the implementation. |
We hit a similar situation trying to implement a hook for use with Meteor, and I thought I'd share a few things. For context, the Meteor API works such that you both grab initial data immediately, and set up a side-effect (a computation) at the same time. We want to be able to that synchronously with the render, since we can get data immediately from our data source. (We can run the reactiveFn without setting up the computation on render, but then we have to run it a second time in The only thing really missing to get the ideal solution, is some way to determine when the component is tossed/the render is discarded. If there was some way to check that, either through a hook or just an imperative function to check, that would be enough - then we could simply do a check and cleanup if needed. Without a first-class way to handle a dead-end render, I still worked out a decent algorithm, which uses a timer and a few checks to determine whether the current render/component has been tossed, and cleans up if it thinks is has been. This is what I thought I'd share, as it looks similar to what you've come up with (at a quick glance, I'm on vacation). I have partly implemented it - I'll finish it next week when I get back from vacation. I'd really love some way to simply detect that a render will be/has been tossed, or an earlier effect hook. |
Sorry, @CaptainN. I'm not really familiar with Meteor. If you can synchronously read the current value (sounds like you can) and subscribe somehow to be notified of changes (don't know if you can) then you should just be able to use this hook once it's published. |
Actually, it looks like we can't use this after all. Meteor's subscribe is not passive, and even it's other "reactive" APIs are not passive, as they all set up a "computation" during the first run of our data method. (I'm not sure if the rest of this is useful or interesting for you, but here it is.) Meteor sort of listens for data sources when it runs a "reactive" function, and sets up whatever data listeners it needs during that first run. An example would be something like this: // using our Meteor hook
const { page, isLoading } = useTracker(() => {
// subscribe to remote data, and fetch
const subscription = Meteor.subscribe('pages', props.pageId);
// grab data from a query directly from collection, which could have offline data
const page = MyCollection.findOne(props.pageId);
return { page, isLoading: !subscription.ready() };
}, [props.pageId];
// page might have data, might not, and isLoading will match the state of subscription So basically, The original version of this hook basically ran the reactive function twice - once synchronously with render to avoid creating side effects, then again in The cleanest solution (from my perspective) would simply do what |
react/packages/use-subscription/src/useSubscription.js Lines 95 to 101 in e276a5e
I still think I must be missing something about why it's not possible for you to read a value or attach a listener without a ton of overhead, but admittedly I don't have time to dig through Meteor and familiarize myself with the API 🙂
This is kind of fundamentally at odds with the rules of React. Render functions are supposed to be pure. Side effects are particularly problematic and will only become more so with new APIs like suspense and concurrent mode. |
The reason it's challenging to grab data from a Meteor reactive function without invoking it twice, is that they are very open ended. We don't really know what reactive APIs the user may plug in to, nor do we know what the shape of the data we get back might be. It basically just listens for anything that is registered as a "reactive" source, and when it sees access to a reactive data source, it wires up the subscription to it at the time of first access, and then returns whatever data the user wants. For pure functions, that means the only safe way to set everything up, is to run the reactive function once without side effects (no computations) then run everything again later. I thought of trying to do a deep compare on the date returned, but that gets problematic for a number of reason. I have thought of trying to do more API specific hooks (one for subscriptions, one for each collection method, etc.) but it's a lot more work, and I'm not sure it would solve the problem in every case, since there's a lot of stuff out there. I understand that React wants pure functions for components, but in this case at least, its adding a lot of overhead, instead of reducing it. I suppose the long term solution is to switch to something else - like Apollo. A practical approach would be to simply have a way to clean things up reliably when components are destroyed or discarded. |
For what it's worth, it isn't just an idealistic thing. Purity in render functions enables a lot of powerful APIs (like error boundaries, suspense, and concurrent mode). |
Would adding a simple disposed hook prevent boundaries, suspense, concurrent mode, etc. from working? A practical solution (to my particular problem) would be some way to know when the render is tossed away. A better solution would be some way to migrate side-effects to the next instance (maybe |
A key part of concurrent mode being able to work efficiently is that it must be able to "throw out" work quickly in the event of a high pri interruption. To my knowledge, this is the main reason we have pushed back against any kind of opt-in "cleanup" like you're describing. It would require us to traverse and call cleanup actions (running user code that might be slow) before we could throw away work for something higher priority.
There is no guarantee of a "next instance". Application trees can change drastically between renders. |
I wonder then if a timeout system like I described can make sense here, to basically do what I want inline, then cancel a cleanup timeout in I also thought of using |
Effects don't get run when renders...
|
I think I understand the issue now - thank you for taking the time to explain it. |
One more question, if you have a moment. Does the |
We suggest using the passive effect ( |
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.
👍
@bvaughn is this hook going to be replaced by |
Yes. |
- [x] Make sure the linting passes by running `yarn lint` Back in 2019, React released the first version of `use-subscription` (facebook/react#15022). At the time, we only has limited information about concurrent rendering, and #9026 add the initial concurrent mode support. In 2020, React provides a first-party official API `useMutableSource` (reactjs/rfcs#147, facebook/react#18000): > ... enables React components to safely and efficiently read from a mutable external source in Concurrent Mode. React 18 introduces `useMutableSource`'s replacement `useSyncExternalStore` (see details here: reactwg/react-18#86), and React changes `use-subscription` implementation to use `useSyncExternalStore` directly: facebook/react#24289 > In React 18, `React.useSyncExternalStore` is a built-in replacement for `useSubscription`. > > This PR makes `useSubscription` simply use `React.useSyncExternalStore` when available. For pre-18, it uses a `use-sync-external-store` shim which is very similar in `use-subscription` but fixes some flaws with concurrent rendering. And according to `use-subscription`: > You may now migrate to [`use-sync-external-store`](https://www.npmjs.com/package/use-sync-external-store) directly instead, which has the same API as `React.useSyncExternalStore`. The `use-subscription` package is now a thin wrapper over `use-sync-external-store` and will not be updated further. The PR does exactly that: - Removes the precompiled `use-subscription` introduced in #35746 - Adds the `use-sync-external-store` to the dependencies. - The `use-sync-external-store` package enables compatibility with React 16 and React 17. - Do not pre-compile `use-sync-external-store` since it is also the dependency of some popular React state management libraries like `react-redux`, `zustand`, `valtio`, `@xstate/react` and `@apollo/client`, etc. By install - Replace `useSubscription` usage with `useSyncExternalStore` --- Ref: #9026, #35746 and #36159 Co-authored-by: Jiachi Liu <4800338+huozhi@users.noreply.github.com>
I recently shared an example
useSubscription
hook as a gist. Like thecreateSubscription
class approach it was based on, this has a lot of subtle nuance– so it seems like maybe something that we should consider releasing an "official" version of (along with perhapsuseFetch
).Here is the hook and some unit tests for discussion purposes.
For now I've added it inside of a new private package
react-hooks
(even though that name is already taken). We can bikeshed a real name later, before releasing (assuming we actually decide to do so).Example usage: