Reactでページ(URL)間のアニメーションを直列に表現したいということがありました。
公式のアドオンとして、ReactCSSTransitionGroupReactTransitionGroupというアニメーションのためのコンポーネントが提供されていますが、それらはクロスフェードするようなアニメーションが前提になっています。つまり、現在のページのアニメーションが終了するのを待ってから、次のページのアニメーションを実行するという機能を提供していません。

Webサイトとして凝った演出をしたいとき、URLをまたいだときの処理として、現在のページを離脱するときのアニメーションが終わった後、次のページが徐々に現れるという風にしたいです。
そのため、それらのアニメーションを直列に実行する必要があります。

ReactTransitionGroupはアニメーションのためのAPIを提供していますが、それだけではやりたいことが実現できません。そのため、それらをラップしてキューを管理するためのコンポーネント を作ります。例えば、以下のように実装します。全部飛ばしてソースを見たい人はここにあります。

まず、ロケーションの管理は以下のように行います。

const React = require('react');
const {render} = require('react-dom');
const history = require('./history'); // instance of history

const App = ({location}) => (
  <div>
    <Header />
    <PageContainer location={location} />
  </div>
);

const renderApp = location => {
  render(<App location={location} />, mountNode);
};

renderApp(history.getCurrentLocation());
history.listen(renderApp);

URLの変更があると、Reactのコンポーネントに対してlocationオブジェクトが渡されます。
ブラウザのロケーションを管理するためのライブラリとして、mjackson/historyを利用しています。

PageContainerでは、stateとして持ったlocationを基に、ページにマッチするコンポーネントを選択してTransitionGroupchildrenとして渡します。初期stateにはprops.locationをセットして、以後はlocationが渡ってくるたびに、setStateするためのキューを溜めます。

キューを溜めるための機構としてmizchi/promised-reducerを、子のコンポーネントとやり取りするためにEventEmitterを利用します。

const {EventEmitter} = require('events');
const React = require('react');
const TransitionGroup = require('react-addons-transition-group');
import PromisedReducer from 'promised-reducer';

const matchURI = ({pathname}, dispatch) => {
  switch (pathname) {
    case '/':
      return <HomePage key={pathname} dispatch={dispatch} />;
    case '/about':
      return <AboutPage key={pathname} dispatch={dispatch} />;
    default:
      return null;
  }
};

class PageContainer extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      location: props.location
    };

    const reducer = new PromisedReducer();
    reducer.on(':update', state => this.setState(state));

    const emitter = new EventEmitter();
    const subscribe = emitter.on.bind(emitter);

    subscribe('push-queue', queue => {
      reducer.update(state => queue().then(() => state));
    });
    subscribe('update-location', location => {
      reducer.update(state => ({location}));
    });

    this.dispatch = emitter.emit.bind(emitter);
  }

  componentWillReceiveProps(nextProps) {
    if (nextProps.location.pathname !== this.props.location.pathname) {
      this.dispatch('update-location', nextProps.location);
    }
  }

  render() {
    const matchComponent = matchURI(this.state.location, this.dispatch);

    return (
      <TransitionGroup component="div">
        {matchComponent}
      </TransitionGroup>
    );
  }
}

PromisedReducerでは、PromisedReducer#updateに渡したキューを連結して、それら全てが終了したタイミングでPromisedReducer#on(':update')に渡したコールバックが呼ばれます。

全体の流れとしてはこういう風になっています。

初回マウント時

  1. PageContainerに初期のlocationをstateにセットする
  2. PageContainerからマッチするコンポーネントをTransitionGroupに渡す
  3. URLに対応するコンポーネントのcomponentWillAppearからコンテナにキューが打ち上げられる

URL遷移時

  1. PageContainerlocationが渡り、setStateするキューを溜める
  2. reducerにキューが溜まってなければsetStateされる
  3. 次のURLに対応するコンポーネントをTransitionGroupに渡す
  4. 次のURLに対応するコンポーネントのcomponentWillEnterが発火するが、前のURLのコンポーネントのcomponentWillLeaveの後に実行させたいので、setTimeout(fn, 0)で遅延させておく
  5. 前のURLに対応するコンポーネントのcomponentWillLeaveからキューが打ち上げられる
  6. 遅延して、次のURLに対応するコンポーネントのcomponentWillEnterからキューが打ち上げられる

いずれかのキューの途中でURL遷移のイベントが複数発生しても、中間で渡ったlocationを経由する(TransitionGroupに渡される)ことなく、現在表示されているコンポーネントのcomponentWillLeaveと、最終的なURLにマッチするコンポーネントのcomponentWillEnterのみが実行されます。

それぞれのURLに対応するコンポーネントの実装として以下のようなイメージです。

const React = require('react');
const dynamics = require('dynamics.js');

class HomePage extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      style: {
        display: 'none',
        opacity: 0
      }
    };
  }

  onEnter() {
    return new Promise(done => {
      const nextStyle = {...this.state.style};
      nextStyle.display = '';
      dynamics.animate(nextStyle, {
        opacity: 1
      }, {
        duration: 3000,
        change: style => this.setState({style}),
        complete: done
      });
    });
  }

  onLeave() {
    return new Promise(done => {
      const nextStyle = {...this.state.style};
      dynamics.animate(nextStyle, {
        opacity: 0
      }, {
        duration: 3000,
        change: style => this.setState({style}),
        complete: done
      });
    });
  }

  componentWillAppear(callback) {
    const queue = () => this.onEnter().then(callback);
    this.props.dispatch('push-queue', queue);
  }

  componentWillEnter(callback) {
    // 前のコンポーネントの `componentWillLeave` を待ってから実行する
    setTimeout(() => {
      const queue = () => this.onEnter().then(callback);
      this.props.dispatch('push-queue', queue);
    }, 0);
  }

  componentWillLeave(callback) {
    const queue = () => this.onLeave().then(callback);
    this.props.dispatch('push-queue', queue);
  }

  render() {
    const {style} = this.state;
    return (
      <div style=>
        <h1>HomePage</h1>
        <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
      </div>
    );
  }
}

Reactとあまり関係ありませんが、上の例で使用しているdynamics.jsはオブジェクトの値を変化させるのに便利です。

この仕組みで実際に動いているデモと、そのソースです。


なんとかやりたいことは実現できましたが、あまりきれいなやり方でできなかったという感じです。もうちょっとまともな方法があったら教えてください。