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 March 5, 2019 20:51
@bvaughn bvaughn changed the title Use subscription useSubscription hoopk Mar 5, 2019
@bvaughn bvaughn changed the title useSubscription hoopk useSubscription hook Mar 5, 2019
@TrySound
Copy link
Contributor

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) {
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification!

Copy link
Collaborator

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.

Copy link
Contributor Author

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:

  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?

Copy link
Collaborator

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?

Copy link
Contributor Author

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

Copy link
Collaborator

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

Copy link
Collaborator

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.

@bvaughn
Copy link
Contributor Author

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) {
Copy link
Collaborator

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.

Copy link
Contributor Author

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:

  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.

Copy link
Collaborator

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.

Copy link
Contributor Author

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.

@acdlite
Copy link
Collaborator

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 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
Collaborator

gaearon commented Mar 5, 2019

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

@acdlite
Copy link
Collaborator

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 commented Mar 6, 2019

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

@bvaughn
Copy link
Contributor Author

bvaughn commented Mar 6, 2019

Updated!

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

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.

@gaearon
Copy link
Collaborator

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 commented Mar 12, 2019

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

@viankakrisna
Copy link

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

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

@bvaughn
Copy link
Contributor Author

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
Copy link
Contributor Author

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

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

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 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 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 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 commented Aug 5, 2019

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

@CaptainN
Copy link

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@HansBrende
Copy link

@bvaughn is this hook going to be replaced by useSyncExternalStore? Or if not, are there ever any benefits to using this hook instead of useSyncExternalStore, or is useSyncExternalStore (and the corresponding shim) better to use in all cases?

@bvaughn
Copy link
Contributor Author

bvaughn commented Jan 26, 2022

is this hook going to be replaced by useSyncExternalStore?

Yes.

@gaearon gaearon mentioned this pull request Mar 29, 2022
kodiakhq bot pushed a commit to vercel/next.js that referenced this pull request May 8, 2022
- [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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet