Skip to content
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

Merged
merged 18 commits into from Jul 16, 2019
Merged

useSubscription hook #15022

merged 18 commits into from Jul 16, 2019

Conversation

@bvaughn
Copy link
Contributor

@bvaughn bvaughn commented Mar 5, 2019

I recently shared an example useSubscription hook as a gist. Like the createSubscription 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 perhaps useFetch).

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:

import React, { useMemo } from "react";
import useSubscription from "./useSubscription";

// In this example, "source" is an event dispatcher (e.g. an HTMLInputElement)
// but it could be anything that emits an event and has a readable current value.
function Example({ source }) {
  // In order to avoid removing and re-adding subscriptions each time this hook is called,
  // the parameters passed to this hook should be memoized.
  const subscription = useMemo(
    () => ({
      getCurrentValue: () => source.value,
      subscribe: callback => {
        source.addEventListener("change", callback);
        return () => source.removeEventListener("change", callback);
      }
    }),

    // Re-subscribe any time our "source" changes
    // (e.g. we get a new HTMLInputElement target)
    [source]
  );

  const value = useSubscription(subscription);

  // Your rendered output here ...
}
@bvaughn bvaughn requested review from gaearon and acdlite Mar 5, 2019
@bvaughn bvaughn changed the title Use subscription useSubscription hoopk Mar 5, 2019
@bvaughn bvaughn changed the title useSubscription hoopk useSubscription hook Mar 5, 2019
@bvaughn bvaughn force-pushed the bvaughn:useSubscription branch from 9782f08 to decdf93 Mar 5, 2019
@TrySound
Copy link
Contributor

@TrySound TrySound commented Mar 5, 2019

You may ask author about ownership like you did with scheduler package.
https://github.com/tj/react-hooks

});

// If the source has changed since our last render, schedule an update with its current value.
if (state.source !== source) {

This comment has been minimized.

@Andarist

Andarist Mar 5, 2019
Contributor

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?

This comment has been minimized.

@bvaughn

bvaughn Mar 5, 2019
Author Contributor

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.

This comment has been minimized.

@bvaughn

bvaughn Mar 5, 2019
Author Contributor

This is essentially following the pattern we recommend for derived state:
https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops

This comment has been minimized.

@Andarist

Andarist Mar 5, 2019
Contributor

Thanks for the clarification!

This comment has been minimized.

@acdlite

acdlite Mar 5, 2019
Member

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.

This comment has been minimized.

@bvaughn

bvaughn Mar 5, 2019
Author Contributor

So the thing I'm guarding against here (which maybe my comment doesn't do a good job of clarifying) is this:

  1. Subscription added to source A
  2. Component renders with a new source, source B
  3. Source A emits an update
  4. 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?

This comment has been minimized.

@acdlite

acdlite Mar 5, 2019
Member

Is didUnsubscribe not sufficient for that?

This comment has been minimized.

@bvaughn

bvaughn Mar 5, 2019
Author Contributor

No. Because in the case I mentioned above, we haven't unsubscribed yet (because the passive effect wasn't yet fired).

This comment has been minimized.

@acdlite

acdlite Mar 5, 2019
Member

We always fire pending passive effects right at the beginning of setState, to prevent this type of scenario

This comment has been minimized.

@acdlite

acdlite Mar 5, 2019
Member

Ah but I guess that would require closing over a mutable variable. Ok now I get it.

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Mar 5, 2019

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) {

This comment has been minimized.

@acdlite

acdlite Mar 5, 2019
Member

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.

This comment has been minimized.

@bvaughn

bvaughn Mar 5, 2019
Author Contributor

This check might be useful in two scenarios:

  1. Our manual call to checkForUpdates() in the passive effect body (a few lines below).
  2. 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.

This comment has been minimized.

@acdlite

acdlite Mar 5, 2019
Member

How are 1 and 2 different? I would expect that if you have 1 (the manual call) you don't need 2.

This comment has been minimized.

@bvaughn

bvaughn Mar 5, 2019
Author Contributor

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.

@acdlite
Copy link
Member

@acdlite acdlite commented Mar 5, 2019

It occurs to me that source serves the same function as a deps array. Have you considered using a deps API instead? Like

// 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]);
}

[edit: updated to remove extra function call; now closer to useEffect] Never mind, that doesn't work

Aside from consistency between APIs, this also lets you depend on multiple values changing, and you don't need to add an extra useMemo if you want to optimize resubscribing.

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Mar 5, 2019

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).

@gaearon
Copy link
Member

@gaearon gaearon commented Mar 5, 2019

I can add an exception if it's a recommended one.

@acdlite
Copy link
Member

@acdlite acdlite commented Mar 6, 2019

@gaearon Is figuring out a heuristic for custom hooks on the roadmap? Seems important. (Although I don't have any ideas.)

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Mar 6, 2019

Okay. I'll update this PR with the proposed API change then.

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Mar 6, 2019

Updated!

@bvaughn bvaughn force-pushed the bvaughn:useSubscription branch 3 times, most recently from 31af392 to 87f7c31 Mar 6, 2019
@bvaughn bvaughn requested review from acdlite and removed request for acdlite Mar 6, 2019
@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Mar 7, 2019

Based on the outcome of our chat this morning, I've reverted the dependencies array change in favor of the previous useMemo approach. I've also updated the shared gist.

Back to you for review, @acdlite.

@bvaughn bvaughn force-pushed the bvaughn:useSubscription branch from b3e8a0a to 140cdb8 Mar 11, 2019
@gaearon
Copy link
Member

@gaearon gaearon commented Mar 12, 2019

Does the first example in the PR post need updating? I got confused for a second.

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Mar 12, 2019

Ah, yeah. I updated the Gist but forgot about the PR description example.

@viankakrisna
Copy link

@viankakrisna viankakrisna commented Mar 13, 2019

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);

This comment has been minimized.

@viankakrisna

viankakrisna Mar 13, 2019

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);
}
@bvaughn bvaughn force-pushed the bvaughn:useSubscription branch from 140cdb8 to 64f0b44 Mar 14, 2019
@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Mar 14, 2019

I've updated this proposal again with a less redundant API that leans more heavily on useMemo (or useCallback).

@bvaughn bvaughn merged commit ce883a1 into facebook:master Jul 16, 2019
12 checks passed
12 checks passed
ci/circleci: build Your tests passed on CircleCI!
Details
ci/circleci: flow Your tests passed on CircleCI!
Details
ci/circleci: lint Your tests passed on CircleCI!
Details
ci/circleci: lint_build Your tests passed on CircleCI!
Details
ci/circleci: process_artifacts Your tests passed on CircleCI!
Details
ci/circleci: setup Your tests passed on CircleCI!
Details
ci/circleci: test_build Your tests passed on CircleCI!
Details
ci/circleci: test_build_prod Your tests passed on CircleCI!
Details
ci/circleci: test_dom_fixtures Your tests passed on CircleCI!
Details
ci/circleci: test_source Your tests passed on CircleCI!
Details
ci/circleci: test_source_persistent Your tests passed on CircleCI!
Details
ci/circleci: test_source_prod Your tests passed on CircleCI!
Details
@bvaughn bvaughn deleted the bvaughn:useSubscription branch Jul 16, 2019
@MargaretKrutikova
Copy link

@MargaretKrutikova MargaretKrutikova commented Jul 17, 2019

Amazing work!
I was wondering, why wouldn't it be suitable for subscribing to a redux store? In the readme it says:

Redux/Flux stores should use the context API instead.

As far as I remember @sebmarkbage warned about using context for such purposes in this comment:

My personal summary is that new context is ready to be used for low frequency unlikely updates (like locale/theme). It's also good to use it in the same way as old context was used. I.e. for static values and then propagate updates through subscriptions. It's not ready to be used as a replacement for all Flux-like state propagation.

Has there been any updates on this and context can now be used even for frequent updates (which is the case with most applications using redux)?

I feel like this is an important topic for many of us using global state management libraries like redux and I would be grateful for any comments regarding using context/useSubscription for this!

@Andarist
Copy link
Contributor

@Andarist Andarist commented Jul 17, 2019

Has there been any updates on this and context can now be used even for frequent updates (which is the case with most applications using redux)?

AFAIK there were no changes, redux had to "revert" their changes using native context for change propagation and had to reimplement custom subscriptions - they use context to share store/subscription instances though but those rarely change.

@MargaretKrutikova
Copy link

@MargaretKrutikova MargaretKrutikova commented Jul 17, 2019

Right, I think I remember reading about this in some issue in react-redux.
I was hoping to be able to use the useSubscription hook in react-redux-like hooks for ReasonReact, but according to the README it might not be a good idea 😕

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Jul 17, 2019

@MargaretKrutikova: The opening portion of the README was mostly copied verbatim from the create-subscription README. As for:

why wouldn't it be suitable for subscribing to a redux store

Mostly because of what's described in this section. Sources that frequently change like Redux would likely deopt to sync mode often and you'd lose a lot of the potential benefits of concurrent mode.

At the moment, I think Relay might be one of the only lib that's really been optimized for concurrent mode.

@MargaretKrutikova
Copy link

@MargaretKrutikova MargaretKrutikova commented Jul 17, 2019

Thanks for the clarification! I am still not sure I understand it completely .. according to the README it's okay to use the useSubscription hook for sources with values that change frequently (which is the case with redux):

This utility should be used for subscriptions to a single value that are typically only read in one place and may update frequently

The same is not true for context, which is not suitable for values that are updated often. However, context still remains the recommended way for redux-like sources.

Is there anything I am missing here? Appreciate your time!

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Jul 17, 2019

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.)

@dai-shi
Copy link

@dai-shi dai-shi commented Jul 17, 2019

@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.

@MargaretKrutikova
Copy link

@MargaretKrutikova MargaretKrutikova commented Jul 17, 2019

@bvaughn , thanks a lot for the info!

There is an ongoing discussion in this PR in reductive (global state management for ReasonReact) for implementing hooks, and one suggestion was to use your approach with useSubscription instead of the solution for react-redux hooks.

@rickyvetter , this might give us some more insight into how to proceed with the implementation.

@CaptainN
Copy link

@CaptainN CaptainN commented Jul 29, 2019

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 useEffect, which causes 2 renders by default, which is not an ideal default.) We actually have a nice implementation already, but it's not compatible with concurrent mode or with Suspense (in some situations).

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. useMemo would be almost perfect if it had some sort of cleanup method like useEffect (and possibly a semantic guarantee, or at least some clarification on when data might be tossed/cleanup invoked).

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Jul 29, 2019

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.

@CaptainN
Copy link

@CaptainN CaptainN commented Jul 30, 2019

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, Meteor.subscribe and MyCollection.findOne are "reactive" APIs, which means when the data and/or state they receive changes, it'll automatically re-run the function, and return the new data. In Meteor, subscribe works kind of like a fetch to load the data, and when the data has been loaded (subscription.ready()) then the query will return documents (or if some offline stuff is wired up, then it's possible to get documents before isReady()) - but that's all advanced Meteor stuff).

The original version of this hook basically ran the reactive function twice - once synchronously with render to avoid creating side effects, then again in useEffect to set up the side-effects (build the computation). But performance was a problem (every component instantiation ran render twice), and we had to do ugly monkey patching with Meteor.subscribe to avoid problems. (and that would almost certainly miss other side-effect creating Meteor APIs from third parties). Coordinating things when deps changed (including a specific case where it was not obvious - such as when used by top level components inside a react-router Switch statement) was also difficult.

The cleanest solution (from my perspective) would simply do what useEffect does, but do it synchronously with render, with the clean up method called when the render is tossed away (which is what the timeout method I mentioned does). Even some simple way to determine when the render is tossed would be enough to build on. I guess I'll continue to look for alternative ways to cleanup side-effects we create during render, when the render is tossed. Maybe react-cache can be used? Actually an even more ideal solution would be to transfer the side effect we created from a tossed render to the next render of the same instance.

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Jul 30, 2019

But performance was a problem (every component instantiation ran render twice)

useSubscription compares values to avoid a second (unnecessary) render.

// 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.
const value = getCurrentValue();
if (prevState.value === value) {
return prevState;
}

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 🙂

I guess I'll continue to look for alternative ways to cleanup side-effects we create during render

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.

@CaptainN
Copy link

@CaptainN CaptainN commented Jul 30, 2019

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.

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Jul 30, 2019

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.

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).

@CaptainN
Copy link

@CaptainN CaptainN commented Jul 31, 2019

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 react-cache can help there?), but some signal that the render isn't sticking around would be all I really need, and it doesn't seem like it would block any of that other stuff.

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Jul 31, 2019

A practical solution (to my particular problem) would be some way to know when the render is tossed away.

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.

A better solution would be some way to migrate side-effects to the next instance

There is no guarantee of a "next instance". Application trees can change drastically between renders.

@CaptainN
Copy link

@CaptainN CaptainN commented Aug 1, 2019

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 useEffect (with some other checks). The only question I have for that is how long to wait - I figure 25ms is enough, but maybe it's too long? Will a committed render (one which will run useEffect) ever take more than 25ms?

I also thought of using useLayoutEffect which would take care of the flash of no content, at the cost of offloading some work to our users who would have to code around a null value on first render. I guess that's not the end of the world (though it precludes a backwards compatible way to do what we'd like for our old withTracker HOC since that needs to run synchronously.

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Aug 1, 2019

Effects don't get run when renders...

  • are interrupted because of higher priority work
  • throw an error and unwind to an error boundary
  • throw a promise and suspend (to be run again later)

@CaptainN
Copy link

@CaptainN CaptainN commented Aug 5, 2019

I think I understand the issue now - thank you for taking the time to explain it.

@CaptainN
Copy link

@CaptainN CaptainN commented Aug 6, 2019

One more question, if you have a moment. Does the useEffect hook (and maybe componentDidMount timing is similar?) always run right after the first render in cases when it will run? Does it run in the same thread (maybe not the right term), or within a certain amount of time (under 1ms, for example)? Or is the time between render and useEffect more indeterminate?

@bvaughn
Copy link
Contributor Author

@bvaughn bvaughn commented Aug 6, 2019

componentDidMount and useLayoutEffect both run before paint. useEffect yields (so the browser can paint) before running.

We suggest using the passive effect (useEffect) for things that don't affect display, like logging or setting timeouts etc. Use layout effect for e.g. adjusting tooltip position or size, things you want to tweak before the user sees your component's rendered output.

Copy link

@beizhedenglong beizhedenglong left a comment

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked issues

Successfully merging this pull request may close these issues.

None yet