Skip to content

Instantly share code, notes, and snippets.

@bvaughn
Last active September 30, 2018 09:55
Show Gist options
  • Save bvaughn/402bd799c1d0d64a5f1800f1f6e810ac to your computer and use it in GitHub Desktop.
Save bvaughn/402bd799c1d0d64a5f1800f1f6e810ac to your computer and use it in GitHub Desktop.
create-subscriber-component proof of concept
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);
@bvaughn
Copy link
Author

bvaughn commented Mar 4, 2018

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