Last active
September 30, 2018 09:55
-
-
Save bvaughn/402bd799c1d0d64a5f1800f1f6e810ac to your computer and use it in GitHub Desktop.
create-subscriber-component proof of concept
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
type SubscribableConfig = { | |
// Maps property names of subscribable data sources (e.g. 'someObservable'), | |
// To state names for subscribed values (e.g. 'someValue'). | |
subscribablePropertiesMap: {[subscribableProperty: string]: string}, | |
// Synchronously get data for a given subscribable property. | |
// It is okay to return null if the subscribable does not support sync value reading. | |
getDataFor: (subscribable: any, propertyName: string) => any, | |
// Subscribe to a given subscribable. | |
// Due to the variety of change event types, subscribers should provide their own handlers. | |
// Those handlers should NOT update state though; they should call the valueChangedCallback() instead. | |
subscribeTo: ( | |
valueChangedCallback: (value: any) => void, | |
subscribable: any, | |
propertyName: string, | |
) => any, | |
// Unsubscribe from a given subscribable. | |
// The optional subscription object returned by subscribeTo() is passed as a third parameter. | |
unsubscribeFrom: ( | |
subscribable: any, | |
propertyName: string, | |
subscription: any, | |
) => void, | |
}; | |
// TODO Decide how to handle missing subscribables. | |
export function createComponent( | |
config: SubscribableConfig, | |
Component: React$ComponentType<*>, | |
): React$ComponentType<*> { | |
const { | |
getDataFor, | |
subscribablePropertiesMap, | |
subscribeTo, | |
unsubscribeFrom, | |
} = config; | |
class SubscribableContainer extends React.Component { | |
state = {}; | |
static getDerivedStateFromProps(nextProps, prevState) { | |
const nextState = {}; | |
let hasUpdates = false; | |
// Read value (if sync read is possible) for upcoming render | |
for (let propertyName in subscribablePropertiesMap) { | |
const prevSubscribable = prevState[propertyName]; | |
const nextSubscribable = nextProps[propertyName]; | |
if (prevSubscribable !== nextSubscribable) { | |
nextState[propertyName] = { | |
...prevState[propertyName], | |
subscribable: nextSubscribable, | |
value: | |
nextSubscribable != null | |
? getDataFor(nextSubscribable, propertyName) | |
: undefined, | |
}; | |
hasUpdates = true; | |
} | |
} | |
return hasUpdates ? nextState : null; | |
} | |
componentDidMount() { | |
for (let propertyName in subscribablePropertiesMap) { | |
const subscribable = this.props[propertyName]; | |
this.subscribeTo(subscribable, propertyName); | |
} | |
} | |
componentDidUpdate(prevProps, prevState) { | |
for (let propertyName in subscribablePropertiesMap) { | |
const prevSubscribable = prevProps[propertyName]; | |
const nextSubscribable = this.props[propertyName]; | |
if (prevSubscribable !== nextSubscribable) { | |
this.unsubscribeFrom(prevSubscribable, propertyName); | |
this.subscribeTo(nextSubscribable, propertyName); | |
} | |
} | |
} | |
componentWillUnmount() { | |
for (let propertyName in subscribablePropertiesMap) { | |
const subscribable = this.props[propertyName]; | |
this.unsubscribeFrom(subscribable, propertyName); | |
} | |
} | |
// Event listeners are only safe to add during the commit phase, | |
// So they won't leak if render is interrupted or errors. | |
subscribeTo(subscribable, propertyName) { | |
if (subscribable != null) { | |
const wrapper = this.state[propertyName]; | |
const valueChangedCallback = value => { | |
this.setState(state => { | |
const currentWrapper = state[propertyName]; | |
// If this event belongs to the current data source, update state. | |
// Otherwise we should ignore it. | |
if (subscribable === currentWrapper.subscribable) { | |
return { | |
[propertyName]: { | |
...currentWrapper, | |
value, | |
}, | |
}; | |
} | |
return null; | |
}); | |
}; | |
// Store subscription for later (in case it's needed to unsubscribe). | |
// This is safe to do via mutation since: | |
// 1) It does not impact render. | |
// 2) This method will only be called during the "commit" phase. | |
wrapper.subscription = subscribeTo( | |
valueChangedCallback, | |
subscribable, | |
propertyName, | |
); | |
// External values could change between render and mount, | |
// In some cases it may be important to handle this case. | |
const value = getDataFor(subscribable, propertyName); | |
if (value !== wrapper.value) { | |
this.setState({ | |
[propertyName]: { | |
...wrapper, | |
value, | |
}, | |
}); | |
} | |
} | |
} | |
unsubscribeFrom(subscribable, propertyName) { | |
if (subscribable != null) { | |
const wrapper = this.state[propertyName]; | |
unsubscribeFrom(subscribable, propertyName, wrapper.subscription); | |
wrapper.subscription = null; | |
} | |
} | |
render() { | |
const filteredProps = {}; | |
const subscribedValues = {}; | |
for (let key in this.props) { | |
if (!subscribablePropertiesMap.hasOwnProperty(key)) { | |
filteredProps[key] = this.props[key]; | |
} | |
} | |
for (let fromProperty in subscribablePropertiesMap) { | |
const toProperty = subscribablePropertiesMap[fromProperty]; | |
const wrapper = this.state[fromProperty]; | |
subscribedValues[toProperty] = | |
wrapper != null ? wrapper.value : undefined; | |
} | |
return <Component {...filteredProps} {...subscribedValues} />; | |
} | |
} | |
return SubscribableContainer; | |
} | |
// 2: Below is an example of using the subscribable HOC. | |
// It shows a couple of potentially common subscription types. | |
function ExampleComponent(props: Props) { | |
const { | |
observedValue, | |
promisedValue, | |
relayData, | |
scrollTop, | |
} = props; | |
// The rendered output is not interesting. | |
// The interesting thing is the incoming props/values. | |
} | |
function getDataFor(subscribable, propertyName) { | |
switch (propertyName) { | |
case 'fragmentResolver': | |
return subscribable.resolve(); | |
case 'observableStream': | |
// This only works for some observable types (e.g. BehaviorSubject) | |
// It's okay to just return null/undefined here for other types. | |
return subscribable.getValue(); | |
case 'promise': | |
// No sync way to read value from a Promise. | |
return null; | |
case 'scrollTarget': | |
return subscribable.scrollTop; | |
default: | |
throw Error(`Invalid subscribable, "${propertyName}", specified.`); | |
} | |
} | |
function subscribeTo(valueChangedCallback, subscribable, propertyName) { | |
switch (propertyName) { | |
case 'fragmentResolver': | |
subscribable.setCallback( | |
() => valueChangedCallback(subscribable.resolve() | |
); | |
break; | |
case 'observableStream': | |
// Return the subscription; it's necessary to unsubscribe. | |
return subscribable.subscribe(valueChangedCallback); | |
case 'promise': | |
let subscribed = true; | |
subscribable.then(value => { | |
if (subscribed) { | |
valueChangedCallback(value); | |
} | |
}); | |
// Promises can't be "unsuscribed" from, but we can fake it, | |
// By ignoring resolved values if an unsubscribe has been requested. | |
return () => { | |
subscribed = false; | |
}; | |
case 'scrollTarget': | |
const onScroll = () => valueChangedCallback(subscribable.scrollTop); | |
subscribable.addEventListener('scroll', onScroll); | |
return onScroll; | |
default: | |
throw Error(`Invalid subscribable, "${propertyName}", specified.`); | |
} | |
} | |
function unsubscribeFrom(subscribable, propertyName, subscription) { | |
switch (propertyName) { | |
case 'fragmentResolver': | |
subscribable.dispose(); | |
break; | |
case 'observableStream': | |
// Unsubscribe using the subscription rather than the subscribable. | |
subscription.unsubscribe(); | |
case 'promise': | |
// Simulate unsubscription via our wrapper function. | |
// Promise resolution will just be ignored. | |
subscription.unsubscribe(); | |
break; | |
case 'scrollTarget': | |
// In this case, 'subscription', is the event handler/function. | |
subscribable.removeEventListener(subscription); | |
break; | |
default: | |
throw Error(`Invalid subscribable, "${propertyName}", specified.`); | |
} | |
} | |
// 3: This is the component you would export. | |
createComponent({ | |
subscribablePropertiesMap: { | |
fragmentResolver: 'relayData', | |
observableStream: 'observedValue', | |
promise: 'promisedValue', | |
scrollTarget: 'scrollTop', | |
}, | |
getDataFor, | |
subscribeTo, | |
unsubscribeFrom, | |
}, ExampleComponent); |
Inspiration for the design of the SubscriberContainer
HOC came from this gist:
https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3
WIP PR posted to facebook/react#12325
Looks good - I really like it. Maybe refine the Flow types though and remove the any
types, especially with subscribable
as it's nice to know exactly what is and what is not valid for that control flow.
Thanks @trueadm.
I'm not sure how much value there is in trying to remove the any types in this case though. A subscribable can literally be of any type (e.g. event dispatcher, observable, etc).
Maybe there's a way to specify Flow generic types here, and tie them into the property names. I don't know how though.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The above example won't work quite right for Promises (or any type without support for sync-value-read), because the "commit" phase check will override a resolved value with null/undefined.
I could work around this by passing the subscription to getData (and using it to cache the resolved value) but... I think maybe just dropping support for Promise (and certain types of observable) might be better. It was a bit of an awkward case anyway.