The Flux model mandates that any changes in state initiated at the views, fire actions that work through the dispatcher and propagate down a store back to listening views.
This is all nice and dandy. But what if I need to perform some DOM operation after a specific change in state? In real world apps, we sometimes have to use that, you know, relic called jQuery plugins (remember those?)
For example, I need to initialize a jQuery video player, à la $('#videoContainer').initPlayer(streamURL)
, after an Ajax request to retrieve streamURL
. Please dedicate a moment to figure out how you would do that with Flux/React, before reading on).
On the view part, I might use componentWillReceiveProps
:
var jsVideoPlayer = React.createClass({
componentWillReceiveProps: function(nextProps) {
$(this.refs.videoContainer.getDOMNode()).initPlayer(nextProps.streamURL);
},
render: function() {
return (
<div ref="videoContainer">
</div>
);
}
});
But here, the player would get initialized each time the state updates. That would surely break our video player. So let's check if the URL has changed before we actually do anything:
componentWillReceiveProps: function(nextProps) {
if (nextProps.streamURL && nextProps.streamURL !== this.streamURL) {
this.streamURL = nextProps.streamURL;
$(this.refs.videoContainer.getDOMNode()).initPlayer(nextProps.streamURL);
}
},
Easy, right? But what about the bigger picture? What if we scale that up - and wide - as we add more functionality and UI elements, that could lead to more custom validation code in the views on what's coming down from the store. Even in a perfect app, where every store has it's own domain and everything is laid out spotlessly in design patterns on a level worthy of a dedicated religious following, we would still have to use more and more logic in the views as the app would scale.
In our team, I argued this logic should really go into a ControllerView, which fits the Flux model - but everyone else argued this is something the framework should take care of. We are new to Flux/React, and couldn't come up with a better idea on how to make the framework do that for us.
When you identify patterns you can create abstractions. Here the pattern is the need to handle changing props in a simple way. I've been thinking about this problem for a while... this is what I've come up with so far.
propUpdates: {
propName: {
onMount: function(to, el, config){}
onChange: function(from, to, el, config){},
idempotent: false,
compare: 'default'
}
}
It may still be verbose, but it's more declarative and doesn't require a lot of if statements and comparisons. jsbin
var Test = React.createClass({
mixins: [propUpdatesMixin],
propUpdates: {
text: {
onChange: function(from, to, el){
el.textContent = to;
},
idempotent: true
}
},
render: function(){
return <div />;
}
});
onChange is only invoked when the property actually changes. By default this is compared with ===
however you can specify a compare
of "shallow", "deep", or a function which takes the old and new prop values and returns true if they're equal.
If idempotent
is true, onChange is invoked in place of onMount with the first argument being undefined
. Inside onChange and onMount this
is the component instance. To get the object with the onChange/onMount/etc properties, use the last argument (config).
Sometimes you need more control, such as when you need to react to multiple props changing.
componentDidUpdate: function(prevProps){
var changes = this.getPropsChanges(prevProps, this.props);
if (changes.text.changed && changes.color.changed) {
// ...
}
}
The return of getPropsChanges
is mapping of prop names to change objects. Change objects have this structure:
{
changed: Boolean,
from: Any,
to: Any,
name: String (prop name)
config: {onMount,onChange,idempotent,compare}
}
The mixin is designed to not get in your way. You can mix and match strategies. It only requires a propUpdates, but it can be {}
. You can still write your own componentDidMount and use propUpdates only for onChange, or only use propUpdates for onMount and write your own componentWillUpdate, or have an onChange for one prop, and onMount for another, and an idempotent onChange for a third, and do things in componentDidMount and componentDidUpdate.
Play around with the jsbin to see if it fits your needs. For completeness, here's the full code for the mixin.
var comparisons={"shallow":function(a,b){return Object.keys(a).every(function(key){return a[key]!==b[key]})},"deep":function(a,b){return!_.isEqual(a,b)},"default":function(a,b){return a!==b}};var EACH_PROP="__propUpdatesMixin_eachProp__";var PROPS="propUpdates";var propUpdatesMixin={};
propUpdatesMixin.componentDidMount=function(){var el=this.getDOMNode();this[EACH_PROP](function(propName,config,propValue){if(config.onMount)config.onMount.call(this,propValue,el,config);else if(config.onChange&&config.idempotent)config.onChange.call(this,undefined,propValue,el,config)},this.props)};
propUpdatesMixin.componentDidUpdate=function(prevProps){var el=this.getDOMNode();var changes=this.getPropsChanges(prevProps,this.props);Object.keys(changes).forEach(function(propName){var change=changes[propName];if(change.changed&&change.config.onChange)change.config.onChange.call(this,change.from,change.to,el,change.config)},this)};
propUpdatesMixin.getPropsChanges=function(propsA,propsB){var updates={};this[EACH_PROP](function(propName,config,a,b){var compare=typeof config.compare==="function"?config.compare:comparisons[config.compare]?comparisons[config.compare]:comparisons["default"];var changed=compare(a,b);updates[propName]={changed:changed,from:a,to:b,name:propName,config:config}},propsA,propsB);return updates};
propUpdatesMixin[EACH_PROP]=function(fn,propsA,propsB){propsA=propsA||{};propsB=propsB||{};Object.keys(this[PROPS]).map(function(propName){return fn.call(this,propName,this[PROPS][propName],propsA[propName],propsB[propName])},this)};
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With