Search code examples
reactjsgsapmobxreact-transition-group

Using React TransitionGroup, GSAP, and MobX to Re-Render the Same Component


I have a survey-like React application that is rendering various questions to the screen using a variety of UI components.

However, the nature of the survey is that many questions re-render using the exact same component. For instance, an "A / B Selector" or a "Checklist."

What I'd like to achieve is for each component, regardless of whether it is being re-used or mounted to the DOM for the first time, to fade up from the bottom - and fade downward once the user selects the answer.

Here is a very basic example using five questions and a little box:

import React, {Component, PropTypes} from 'react';
import ReactDOM from 'react-dom';

import { observer, Provider, inject } from 'mobx-react';
import { observable, computed, action } from 'mobx';

import 'gsap';
import TransitionGroup from 'react-addons-transition-group';

// STORE
class APP_STORE {

  @observable showingBox = true;
  @observable transDuration = .25;

  @observable questionIndex = 0;

  @observable questions = [
    {text: 'one'},
    {text: 'two'},
    {text: 'three'},
    {text: 'four'},
    {text: 'five'},
  ];

  @computed get currentQuestion() {
    return this.questions[this.questionIndex];
  }

  @action testTrans () {
    this.showingBox = !this.showingBox;
    setTimeout(() => {
      this.questionIndex++;
      this.showingBox = !this.showingBox;
    }, this.transDuration * 1000);
  }

}

let appStore = new APP_STORE();


// TRANSITION w/HIGHER ORDER COMPONENT
function fadesUpAndDown (Component) {
  return (
    class FadesUp extends React.Component {
      componentWillAppear (callback) {
        const el = ReactDOM.findDOMNode(this);
        TweenMax.fromTo(el, appStore.transDuration, {y: 100, opacity: 0}, {y: Math.random() * -100, opacity: 1, onComplete: callback});
      }

      componentWillEnter (callback) {
        const el = ReactDOM.findDOMNode(this);
        TweenMax.fromTo(el, appStore.transDuration, {y: 100, opacity: 0}, {y: Math.random() * -100, opacity: 1, onComplete: callback});
      }

      componentWillLeave (callback) {
        const el = ReactDOM.findDOMNode(this);
        TweenMax.to(el, appStore.transDuration, {y: 100, opacity: 0, onComplete: callback});
      }

      render () {
        return <Component ref="child" {...this.props} />;
      }
    }
  )
}

// REACT

@fadesUpAndDown
class Box extends React.Component {
  render () {
    return <div className="box" ref={c => this.container = c}> {this.props.data.text} </div>;
  }
}


@inject('store') @observer
class Page extends Component {

  render () {
    const store = this.props.store;

    return (
      <div className="page">
        <TransitionGroup>
          { store.showingBox ? <Box data={store.currentQuestion}/> : null }
        </TransitionGroup>

        <button className="toggle-btn" onClick={() => { store.testTrans(); } } >
          test trans
        </button>
      </div>
    );
  }

}

This works! But... to pull it off, I have to manually remove the box component from the DOM (in this case via the showingBox observable), set a timeout for the transition duration, and re-mount the component completely.

Ultimately I guess this is fine but am wondering whether anyone in the SO community has come across a similar scenario with a better way of addressing it since unmount / remount isn't terribly strong React.


Solution

  • RODRIGO'S ORIGINAL ANSWER

    You could try using <TransitionGroup>, a <Transition> tag for each component and on that tag use onEnter and onExit. That's one approach for several components inside a parent component:

    <TransitionGroup className="col-12">
      <Transition
        key={props.location.pathname}
        timeout={500}
        mountOnEnter={true}
        unmountOnExit={true}
        onEnter={node => {
          TweenLite.to(node, 0.5, {
            autoAlpha: 1,
            y: Math.random() * -100
          });
        }}
        onExit={node => {
          TweenLite.to(node, 0.5, {
            position: "fixed",
            autoAlpha: 1,
            y: 0
          });
        }}
      />
    </TransitionGroup>;
    

    Here's an example using this code, but with react router. Obviously is not what you're after but is a working sample using this approach. Go to the components folder, to the routes.js file:

    https://codesandbox.io/s/mQy3mMznn

    The only caveat is that the duration set in the transition group config, should be the same of the GSAP instance in order to keep the mount/unmount in sync, since onEnter and onExit don¡t provide any callback.


    Another option is use the addEndListener method of the <Transition> element:

    <Transition
      in={this.props.in}
      timeout={duration}
      mountOnEnter={true}
      unmountOnExit={true}
      addEndListener={(n, done) => {
        if (this.props.in) {
          TweenLite.to(n, 1, {
            autoAlpha: 1,
            x: 0,
            ease: Back.easeOut,
            onComplete: done
          });
        } else {
          TweenLite.to(n, 1, { autoAlpha: 0, x: -100, onComplete: done });
        }
      }}
    >
      {state =>
        <div className="card" style={{ marginTop: "10px", ...defaultStyle }}>
          <div className="card-block">
            <h1 className="text-center">FADE IN/OUT COMPONENT</h1>
          </div>
        </div>}
    </Transition>
    

    In this case the method does provide the done callback that you can pass to an onComplete handler like in Angular. With that in mind, the only caveat is that the duration in the transition group config should be longer than the time in the GSAP instance, otherwise the component will be unmounted before the animation is complete. If it is longer doesn't matter, the done callback does the unmounting for you.

    Here's a live sample, go to the components folder and into the children.js file:

    https://codesandbox.io/s/yvYE9NNW


    ZFALEN'S DERIVED SOLUTION

    Rodrigo's second suggestion, leveraging the <Transition /> component from react-transition-group (note that this is not the same as react-addons-transition-group) ultimately lead me to a pretty ideal solution.

    By using a static value inside of my MobX store, I can declare a single animation duration and derive it everywhere else. Furthermore, wrapping the <Transition /> as a higher-order component function lets me just use a decorator to indicate what animation any given component should have!

    As long as I pair the animation with the MobX @inject() / <Provider /> pattern, I can basically just declare my GSAP transitions separately, tag the relevant components as needed, and control everything from the store.

    Here's a raw code sample (Note that you will need to have Node spun up with a Webpack / Babel config that supports decorators, etc. and also do a little styling to make stuff appear.):

    import React, {Component, PropTypes} from 'react';
    import ReactDOM from 'react-dom';
    
    import { observer, Provider, inject } from 'mobx-react';
    import { observable, computed, action } from 'mobx';
    
    require('../../../public/stylesheets/transPage.scss');
    
    import 'gsap';
    import Transition from "react-transition-group/Transition";
    
    // LIL UTILITY FUNCTIONS
    const TC = require('../../utils/timeConverter');
    
    // MOBX STORE
    class APP_STORE {
    
      // A TOGGLE & DURATION FOR THE TRANSITION
      @observable showingBox = true;
      transDuration = .25;
    
      @observable questionIndex = 0;
    
      @observable questions = [
        {text: 0 },
      ];
    
      @computed get currentQuestion() {
        return this.questions[this.questionIndex];
      }
    
      @action testTrans () {
    
        // TOGGLE THE COMPONENT TO TRANSITION OUT
        this.showingBox = !this.showingBox;
    
        // WAIT UNTIL THE TRANSITION OUT COMPLETES
        // THEN MAKE CHANGES THAT AFFECT STATE / PROPS
        // THEN TRANSITION THE COMPONENT BACK IN
        setTimeout(() => {
    
          // IN THIS CASE, ADD A NEW 'QUESTION' TO THE SURVEY ARBITRARILY
          this.questions.push({text: this.questionIndex + 1 });
          this.questionIndex++;
    
          this.showingBox = !this.showingBox;
        }, TC.toMilliseconds(this.transDuration) );
      }
    
    }
    
    let appStore = new APP_STORE();
    
    
    // TRANSITION w/HIGHER ORDER COMPONENT
    function fadesUpAndDown (Component) {
      return (
        class FadesUp extends React.Component {
    
          constructor(props) {
            super(props);
          }
    
          render () {
            const store = this.props.store;
    
            return (
              <Transition
                in={store.showingBox}
                timeout={TC.toMilliseconds(store.transDuration)}
                mountOnEnter={true}
                unmountOnExit={true}
                addEndListener={(n, done) => {
                  if (store.showingBox) {
                    TweenLite.to(n, store.transDuration, {
                      opacity: 1,
                      y: -25,
                      ease: Back.easeOut,
                      onComplete: done
                    });
                  } else {
                    TweenLite.to(n, store.transDuration, { opacity: 0, y: 100, onComplete: done });
                  }
                }}
              >
                { state => <Component {...this.props} /> }
              </Transition>
            )
          }
        }
      )
    }
    
    // REACT STUFF
    
    // JUST TAG COMPONENTS WITH THEIR RELEVANT TRANSITION
    @inject("store") @observer @fadesUpAndDown
    class Box extends React.Component {
    
      constructor(props) {
        super(props);
      }
    
      render () {
        return <div className="box" > {this.props.data.text} </div>
      }
    }
    
    
    @inject('store') @observer
    class Page extends Component {
    
      render () {
        const store = this.props.store;
    
        return (
          <div className="page">
            {/* YOU DONT NEED TO EVEN REFERENCE THE TRANSITION HERE */}
            {/* IT JUST WORKS BASED ON DERIVED MOBX VALUES */}
            <Box data={store.currentQuestion} />
    
            <button className="toggle-btn" onClick={() => { store.testTrans(); } } >
              test transition
            </button>
          </div>
        );
      }
    
    }
    
    
    ReactDOM.render(
      <Provider store={appStore}>
        <Page />
      </Provider>,
      document.getElementById('renderDiv')
    );
    
    if (module.hot) {
      module.hot.accept( () => {
        ReactDOM.render(
          <Provider store={appStore}>
            <Page />
          </Provider>,
          document.getElementById('renderDiv')
        )
      })
    }