useSubscription hook (#15022)

* Added use-subscription package with README
This commit is contained in:
Brian Vaughn
2019-07-16 15:59:37 -07:00
committed by GitHub
parent c45c2c3a26
commit ce883a19d8
6 changed files with 861 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
# use-subscription
React hook that safely manages subscriptions in concurrent mode.
## When should you NOT use this?
This utility should be used for subscriptions to a single value that are typically only read in one place and may update frequently (e.g. a component that subscribes to a geolocation API to show a dot on a map).
Other cases have **better long-term solutions**:
* Redux/Flux stores should use the [context API](https://reactjs.org/docs/context.html) instead.
* I/O subscriptions (e.g. notifications) that update infrequently should use a mechanism like [`react-cache`](https://github.com/facebook/react/blob/master/packages/react-cache/README.md) instead.
* Complex libraries like Relay/Apollo should manage subscriptions manually with the same techniques which this library uses under the hood (as referenced [here](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3)) in a way that is most optimized for their library usage.
## Limitations in concurrent mode
`use-subscription` is safe to use in concurrent mode. However, [it achieves correctness by sometimes de-opting to synchronous mode](https://github.com/facebook/react/issues/13186#issuecomment-403959161), obviating the benefits of concurrent rendering. This is an inherent limitation of storing state outside of React's managed state queue and rendering in response to a change event.
The effect of de-opting to sync mode is that the main thread may periodically be blocked (in the case of CPU-bound work), and placeholders may appear earlier than desired (in the case of IO-bound work).
For **full compatibility** with concurrent rendering, including both **time-slicing** and **React Suspense**, the suggested longer-term solution is to move to one of the patterns described in the previous section.
## What types of subscriptions can this support?
This abstraction can handle a variety of subscription types, including:
* Event dispatchers like `HTMLInputElement`.
* Custom pub/sub components like Relay's `FragmentSpecResolver`.
* Observable types like RxJS `BehaviorSubject` and `ReplaySubject`. (Types like RxJS `Subject` or `Observable` are not supported, because they provide no way to read the "current" value after it has been emitted.)
Note that JavaScript promises are also **not supported** because they provide no way to synchronously read the "current" value.
# Installation
```sh
# Yarn
yarn add use-subscription
# NPM
npm install use-subscription
```
# Usage
To configure a subscription, you must provide two methods: `getCurrentValue` and `subscribe`.
In order to avoid removing and re-adding subscriptions each time this hook is called, the parameters passed to this hook should be memoized. This can be done by wrapping the entire subscription with `useMemo()`, or by wrapping the individual callbacks with `useCallback()`.
## Subscribing to event dispatchers
Below is an example showing how `use-subscription` can be used to subscribe to event dispatchers such as DOM elements.
```js
import React, { useMemo } from "react";
import { useSubscription } from "use-subscription";
// In this example, "input" 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({ input }) {
// Memoize to avoid removing and re-adding subscriptions each time this hook is called.
const subscription = useMemo(
() => ({
getCurrentValue: () => input.value,
subscribe: callback => {
input.addEventListener("change", callback);
return () => input.removeEventListener("change", callback);
}
}),
// Re-subscribe any time our input changes
// (e.g. we get a new HTMLInputElement prop to subscribe to)
[input]
);
// The value returned by this hook reflects the input's current value.
// Our component will automatically be re-rendered when that value changes.
const value = useSubscription(subscription);
// Your rendered output goes here ...
}
```
## Subscribing to observables
Below are examples showing how `use-subscription` can be used to subscribe to certain types of observables (e.g. RxJS `BehaviorSubject` and `ReplaySubject`).
**Note** that it is not possible to support all observable types (e.g. RxJS `Subject` or `Observable`) because some provide no way to read the "current" value after it has been emitted.
### `BehaviorSubject`
```js
const subscription = useMemo(
() => ({
getCurrentValue: () => behaviorSubject.getValue(),
subscribe: callback => {
const subscription = behaviorSubject.subscribe(callback);
return () => subscription.unsubscribe();
}
}),
// Re-subscribe any time the behaviorSubject changes
[behaviorSubject]
);
const value = useSubscription(subscription);
```
### `ReplaySubject`
```js
const subscription = useMemo(
() => ({
getCurrentValue: () => {
let currentValue;
// ReplaySubject does not have a sync data getter,
// So we need to temporarily subscribe to retrieve the most recent value.
replaySubject
.subscribe(value => {
currentValue = value;
})
.unsubscribe();
return currentValue;
},
subscribe: callback => {
const subscription = replaySubject.subscribe(callback);
return () => subscription.unsubscribe();
}
}),
// Re-subscribe any time the replaySubject changes
[replaySubject]
);
const value = useSubscription(subscription);
```
+12
View File
@@ -0,0 +1,12 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
'use strict';
export * from './src/useSubscription';
+7
View File
@@ -0,0 +1,7 @@
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/use-subscription.production.min.js');
} else {
module.exports = require('./cjs/use-subscription.development.js');
}
+24
View File
@@ -0,0 +1,24 @@
{
"private": true,
"name": "use-subscription",
"description": "Reusable hooks",
"version": "0.0.0",
"repository": {
"type": "git",
"url": "https://github.com/facebook/react.git",
"directory": "packages/use-subscription"
},
"files": [
"LICENSE",
"README.md",
"build-info.json",
"index.js",
"cjs/"
],
"peerDependencies": {
"react": "^16.8.0"
},
"devDependencies": {
"rxjs": "^5.5.6"
}
}
@@ -0,0 +1,563 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/
'use strict';
let act;
let useSubscription;
let BehaviorSubject;
let React;
let ReactTestRenderer;
let Scheduler;
let ReplaySubject;
describe('useSubscription', () => {
beforeEach(() => {
jest.resetModules();
jest.mock('scheduler', () => require('scheduler/unstable_mock'));
useSubscription = require('use-subscription').useSubscription;
React = require('react');
ReactTestRenderer = require('react-test-renderer');
Scheduler = require('scheduler');
act = ReactTestRenderer.act;
BehaviorSubject = require('rxjs').BehaviorSubject;
ReplaySubject = require('rxjs').ReplaySubject;
});
function createBehaviorSubject(initialValue) {
const behaviorSubject = new BehaviorSubject();
if (initialValue) {
behaviorSubject.next(initialValue);
}
return behaviorSubject;
}
function createReplaySubject(initialValue) {
const replaySubject = new ReplaySubject();
if (initialValue) {
replaySubject.next(initialValue);
}
return replaySubject;
}
it('supports basic subscription pattern', () => {
function Child({value = 'default'}) {
Scheduler.unstable_yieldValue(value);
return null;
}
function Subscription({source}) {
const value = useSubscription(
React.useMemo(
() => ({
getCurrentValue: () => source.getValue(),
subscribe: callback => {
const subscription = source.subscribe(callback);
return () => subscription.unsubscribe();
},
}),
[source],
),
);
return <Child value={value} />;
}
const observable = createBehaviorSubject();
let renderer;
act(() => {
renderer = ReactTestRenderer.create(
<Subscription source={observable} />,
{unstable_isConcurrent: true},
);
});
expect(Scheduler).toHaveYielded(['default']);
// Updates while subscribed should re-render the child component
act(() => observable.next(123));
expect(Scheduler).toHaveYielded([123]);
act(() => observable.next('abc'));
expect(Scheduler).toHaveYielded(['abc']);
// Unmounting the subscriber should remove listeners
act(() => renderer.update(<div />));
act(() => observable.next(456));
expect(Scheduler).toFlushAndYield([]);
});
it('should support observable types like RxJS ReplaySubject', () => {
function Child({value = 'default'}) {
Scheduler.unstable_yieldValue(value);
return null;
}
function Subscription({source}) {
const value = useSubscription(
React.useMemo(
() => ({
getCurrentValue: () => {
let currentValue;
source
.subscribe(tempValue => {
currentValue = tempValue;
})
.unsubscribe();
return currentValue;
},
subscribe: callback => {
const subscription = source.subscribe(callback);
return () => subscription.unsubscribe();
},
}),
[source],
),
);
return <Child value={value} />;
}
let observable = createReplaySubject('initial');
let renderer;
act(() => {
renderer = ReactTestRenderer.create(
<Subscription source={observable} />,
{unstable_isConcurrent: true},
);
});
expect(Scheduler).toHaveYielded(['initial']);
act(() => observable.next('updated'));
expect(Scheduler).toHaveYielded(['updated']);
Scheduler.unstable_flushAll();
// Unsetting the subscriber prop should reset subscribed values
observable = createReplaySubject(undefined);
act(() => renderer.update(<Subscription source={observable} />));
expect(Scheduler).toHaveYielded(['default']);
});
it('should unsubscribe from old sources and subscribe to new sources when memoized props change', () => {
function Child({value = 'default'}) {
Scheduler.unstable_yieldValue(value);
return null;
}
let subscriptions = [];
function Subscription({source}) {
const value = useSubscription(
React.useMemo(
() => ({
getCurrentValue: () => source.getValue(),
subscribe: callback => {
subscriptions.push(source);
const subscription = source.subscribe(callback);
return () => subscription.unsubscribe();
},
}),
[source],
),
);
return <Child value={value} />;
}
const observableA = createBehaviorSubject('a-0');
const observableB = createBehaviorSubject('b-0');
expect(subscriptions).toHaveLength(0);
let renderer;
act(() => {
renderer = ReactTestRenderer.create(
<Subscription source={observableA} />,
{unstable_isConcurrent: true},
);
});
// Updates while subscribed should re-render the child component
expect(Scheduler).toHaveYielded(['a-0']);
expect(subscriptions).toHaveLength(1);
expect(subscriptions[0]).toBe(observableA);
// Unsetting the subscriber prop should reset subscribed values
act(() => renderer.update(<Subscription source={observableB} />));
expect(Scheduler).toHaveYielded(['b-0']);
expect(subscriptions).toHaveLength(2);
expect(subscriptions[1]).toBe(observableB);
// Updates to the old subscribable should not re-render the child component
act(() => observableA.next('a-1'));
expect(Scheduler).toFlushAndYield([]);
// Updates to the bew subscribable should re-render the child component
act(() => observableB.next('b-1'));
expect(Scheduler).toHaveYielded(['b-1']);
expect(subscriptions).toHaveLength(2);
});
it('should unsubscribe from old sources and subscribe to new sources when useCallback functions change', () => {
function Child({value = 'default'}) {
Scheduler.unstable_yieldValue(value);
return null;
}
let subscriptions = [];
function Subscription({source}) {
const value = useSubscription({
getCurrentValue: React.useCallback(() => source.getValue(), [source]),
subscribe: React.useCallback(
callback => {
subscriptions.push(source);
const subscription = source.subscribe(callback);
return () => subscription.unsubscribe();
},
[source],
),
});
return <Child value={value} />;
}
const observableA = createBehaviorSubject('a-0');
const observableB = createBehaviorSubject('b-0');
expect(subscriptions).toHaveLength(0);
let renderer;
act(() => {
renderer = ReactTestRenderer.create(
<Subscription source={observableA} />,
{unstable_isConcurrent: true},
);
});
// Updates while subscribed should re-render the child component
expect(Scheduler).toHaveYielded(['a-0']);
expect(subscriptions).toHaveLength(1);
expect(subscriptions[0]).toBe(observableA);
// Unsetting the subscriber prop should reset subscribed values
act(() => renderer.update(<Subscription source={observableB} />));
expect(Scheduler).toHaveYielded(['b-0']);
expect(subscriptions).toHaveLength(2);
expect(subscriptions[1]).toBe(observableB);
// Updates to the old subscribable should not re-render the child component
act(() => observableA.next('a-1'));
expect(Scheduler).toFlushAndYield([]);
// Updates to the bew subscribable should re-render the child component
act(() => observableB.next('b-1'));
expect(Scheduler).toHaveYielded(['b-1']);
expect(subscriptions).toHaveLength(2);
});
it('should ignore values emitted by a new subscribable until the commit phase', () => {
const log = [];
function Grandchild({value}) {
Scheduler.unstable_yieldValue('Grandchild: ' + value);
return null;
}
function Child({value = 'default'}) {
Scheduler.unstable_yieldValue('Child: ' + value);
return <Grandchild value={value} />;
}
function Subscription({source}) {
const value = useSubscription(
React.useMemo(
() => ({
getCurrentValue: () => source.getValue(),
subscribe: callback => {
const subscription = source.subscribe(callback);
return () => subscription.unsubscribe();
},
}),
[source],
),
);
return <Child value={value} />;
}
class Parent extends React.Component {
state = {};
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.observed !== prevState.observed) {
return {
observed: nextProps.observed,
};
}
return null;
}
componentDidMount() {
log.push('Parent.componentDidMount');
}
componentDidUpdate() {
log.push('Parent.componentDidUpdate');
}
render() {
return <Subscription source={this.state.observed} />;
}
}
const observableA = createBehaviorSubject('a-0');
const observableB = createBehaviorSubject('b-0');
let renderer;
act(() => {
renderer = ReactTestRenderer.create(<Parent observed={observableA} />, {
unstable_isConcurrent: true,
});
});
expect(Scheduler).toHaveYielded(['Child: a-0', 'Grandchild: a-0']);
expect(log).toEqual(['Parent.componentDidMount']);
// Start React update, but don't finish
act(() => {
renderer.update(<Parent observed={observableB} />);
expect(Scheduler).toFlushAndYieldThrough(['Child: b-0']);
expect(log).toEqual(['Parent.componentDidMount']);
// Emit some updates from the uncommitted subscribable
observableB.next('b-1');
observableB.next('b-2');
observableB.next('b-3');
});
// Update again
act(() => renderer.update(<Parent observed={observableA} />));
// Flush everything and ensure that the correct subscribable is used
// We expect the last emitted update to be rendered (because of the commit phase value check)
// But the intermediate ones should be ignored,
// And the final rendered output should be the higher-priority observable.
expect(Scheduler).toHaveYielded([
'Grandchild: b-0',
'Child: b-3',
'Grandchild: b-3',
'Child: a-0',
'Grandchild: a-0',
]);
expect(log).toEqual([
'Parent.componentDidMount',
'Parent.componentDidUpdate',
'Parent.componentDidUpdate',
]);
});
it('should not drop values emitted between updates', () => {
const log = [];
function Grandchild({value}) {
Scheduler.unstable_yieldValue('Grandchild: ' + value);
return null;
}
function Child({value = 'default'}) {
Scheduler.unstable_yieldValue('Child: ' + value);
return <Grandchild value={value} />;
}
function Subscription({source}) {
const value = useSubscription(
React.useMemo(
() => ({
getCurrentValue: () => source.getValue(),
subscribe: callback => {
const subscription = source.subscribe(callback);
return () => subscription.unsubscribe();
},
}),
[source],
),
);
return <Child value={value} />;
}
class Parent extends React.Component {
state = {};
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.observed !== prevState.observed) {
return {
observed: nextProps.observed,
};
}
return null;
}
componentDidMount() {
log.push('Parent.componentDidMount:' + this.props.observed.value);
}
componentDidUpdate() {
log.push('Parent.componentDidUpdate:' + this.props.observed.value);
}
render() {
return <Subscription source={this.state.observed} />;
}
}
const observableA = createBehaviorSubject('a-0');
const observableB = createBehaviorSubject('b-0');
let renderer;
act(() => {
renderer = ReactTestRenderer.create(<Parent observed={observableA} />, {
unstable_isConcurrent: true,
});
});
expect(Scheduler).toHaveYielded(['Child: a-0', 'Grandchild: a-0']);
expect(log).toEqual(['Parent.componentDidMount:a-0']);
log.splice(0);
// Start React update, but don't finish
act(() => {
renderer.update(<Parent observed={observableB} />);
expect(Scheduler).toFlushAndYieldThrough(['Child: b-0']);
expect(log).toEqual([]);
// Emit some updates from the old subscribable
observableA.next('a-1');
observableA.next('a-2');
// Update again
renderer.update(<Parent observed={observableA} />);
// Flush everything and ensure that the correct subscribable is used
// We expect the new subscribable to finish rendering,
// But then the updated values from the old subscribable should be used.
expect(Scheduler).toFlushAndYield([
'Grandchild: b-0',
'Child: a-2',
'Grandchild: a-2',
]);
expect(log).toEqual([
'Parent.componentDidUpdate:b-0',
'Parent.componentDidUpdate:a-2',
]);
});
// Updates from the new subscribable should be ignored.
log.splice(0);
act(() => observableB.next('b-1'));
expect(Scheduler).toFlushAndYield([]);
expect(log).toEqual([]);
});
it('should guard against updates that happen after unmounting', () => {
function Child({value = 'default'}) {
Scheduler.unstable_yieldValue(value);
return null;
}
function Subscription({source}) {
const value = useSubscription(
React.useMemo(
() => ({
getCurrentValue: () => source.getValue(),
subscribe: callback => {
return source.subscribe(callback);
},
}),
[source],
),
);
return <Child value={value} />;
}
const eventHandler = {
_callbacks: [],
_value: true,
change(value) {
eventHandler._value = value;
const _callbacks = eventHandler._callbacks.slice(0);
_callbacks.forEach(callback => callback(value));
},
getValue() {
return eventHandler._value;
},
subscribe(callback) {
eventHandler._callbacks.push(callback);
return () => {
eventHandler._callbacks.splice(
eventHandler._callbacks.indexOf(callback),
1,
);
};
},
};
eventHandler.subscribe(value => {
if (value === false) {
renderer.unmount();
expect(Scheduler).toFlushAndYield([]);
}
});
let renderer;
act(() => {
renderer = ReactTestRenderer.create(
<Subscription source={eventHandler} />,
{unstable_isConcurrent: true},
);
});
expect(Scheduler).toHaveYielded([true]);
// This event should unmount
eventHandler.change(false);
});
it('does not return a value from the previous subscription if the source is updated', () => {
const subscription1 = {
getCurrentValue: () => 'one',
subscribe: () => () => {},
};
const subscription2 = {
getCurrentValue: () => 'two',
subscribe: () => () => {},
};
function Subscription({subscription}) {
const value = useSubscription(subscription);
if (value !== subscription.getCurrentValue()) {
throw Error(
`expected value "${subscription.getCurrentValue()}" but got value "${value}"`,
);
}
return null;
}
let renderer;
act(() => {
renderer = ReactTestRenderer.create(
<Subscription subscription={subscription1} />,
{unstable_isConcurrent: true},
);
});
Scheduler.unstable_flushAll();
act(() => renderer.update(<Subscription subscription={subscription2} />));
Scheduler.unstable_flushAll();
});
});
@@ -0,0 +1,123 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {useDebugValue, useEffect, useState} from 'react';
// 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().
export function useSubscription<Value>({
// (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,
}: {|
getCurrentValue: () => Value,
subscribe: (callback: Function) => () => void,
|}): Value {
// 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.)
const [state, setState] = useState(() => ({
getCurrentValue,
subscribe,
value: getCurrentValue(),
}));
let valueToReturn = 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.
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)
useEffect(
() => {
let didUnsubscribe = false;
const 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) {
return;
}
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
) {
return prevState;
}
// 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;
}
return {...prevState, value};
});
};
const 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();
return () => {
didUnsubscribe = true;
unsubscribe();
};
},
[getCurrentValue, subscribe],
);
// Return the current value for our caller to use while rendering.
return valueToReturn;
}