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

This was referenced Nov 8, 2024
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.