In Flux/React.js, where to initialize jQuery plugins?
Asked Answered
E

1

8

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.

The Flux Model

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.

Erund answered 2/12, 2014 at 17:34 Comment(3)
why not just put this in the componentDidMount or componentDidUpdate?Insistence
@Insistence because it is dependent on an ajax request that only happens when a user clicks a buttonErund
Why not just using an action such as VIDEO_URL_RETRIEVED to update a state in your store ? Your controller-view component will receive the updated state and in your jsVideoPlayer if the props streamUrl is not null then you can initialize your jQuery plugin. If you component has a pure render method, it will only be updated once : when the VIDEO_URL_RETRIEVED action will be triggered.Crasis
R
5

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)};
Reciprocity answered 2/12, 2014 at 19:43 Comment(1)
This is actually pretty good, and very impressive. It showcases, what I believe, how the React universe challenges developers to think more about their architecture. Thank you very much for the jsbin - what an excellent answer. I am tempted to mark it as correct but I want to get more opinions :)Erund

© 2022 - 2024 — McMap. All rights reserved.