pushState in Redux Router

Redux Routerの pushState の使い方と、 React ReduxのconnectmapDispatchToProps について、 connect のSource codeを追いながら解説.

この記事は仮想DOM/Flux Advent Calendar 2015の25日目の記事.

  1. Redux Router
  2. pushState
  3. Usage
  4. mapDispatchToProps
  5. See Also

Redux Router

Redux Router前回で紹介した通り、 React.jsでde facto standardとなっているRouting libraryのReact RouterRedux bindings.

pushState

pushState はReact RouterでURLを遷移するAPIで、元はBrowserのHistory API. Redux Routeの pushState はこれをwrapしたもの.

Usage

前回で使ったApplicationを用意する.

import React, { Component, PropTypes } from 'react';
import { render } from 'react-dom';
import { combineReducers, createStore } from 'redux';
import { connect, Provider } from 'react-redux';
import { createAction, handleActions } from 'redux-actions';
import { IndexRoute, Route, Redirect, Link } from 'react-router';
import { reduxReactRouter, routerStateReducer, ReduxRouter } from 'redux-router';
import createHistory from 'history/lib/createHashHistory';

const INCR_COUNTER = 'INCR_COUNTER';
const incrCounter = createAction(INCR_COUNTER);
const DECR_COUNTER = 'DECR_COUNTER';
const decrCounter = createAction(DECR_COUNTER);

const handleCounter = handleActions({
  INCR_COUNTER: (counter = 0, action) => {
    return counter + 1;
  },
  DECR_COUNTER: (counter = 0, action) => {
    return counter - 1;
  }
}, 0);

const reducer = combineReducers({
  router: routerStateReducer,
  counter: handleCounter
});

@connect(state => {
  return {
    counter: state.counter
  };
})
class App extends Component {
  render() {
    const { counter } = this.props;
    return (
      <div>
        <p>{`COUNTER: ${counter}`}</p>
        {this.props.children}
      </div>
    );
  }
}

@connect(state => {
  return {
    location: state.router.location
  }
})
class CounterButton extends Component {
  render() {
    const { dispatch } = this.props;

    return (
      <button
        onClick={() => {
          if(this.props.location.pathname === '/incr') {
            dispatch(incrCounter());
          } else {
            dispatch(decrCounter());
          }
        }} >
        {this.props.children}
      </button>
    );
  }
}

class Increment extends Component {
  render() {
    return (
      <div>
        <CounterButton>INCREMENT</CounterButton>
        <Link to='/decr'>
          TO DECREMENT
        </Link>
      </div>
    );
  }
}

class Decrement extends Component {
  render() {
    return (
      <div>
        <CounterButton>DECREMENT</CounterButton>
        <Link to='/'>
          TO INCREMENT
        </Link>
      </div>
    );
  }
}

const routes = (
  <Route>
    <Redirect from="/" to="incr" />
    <Route path="/" component={App}>
      <Route path="incr" component={Increment} />
      <Route path="decr" component={Decrement} />
    </Route>
  </Route>
);

const store = reduxReactRouter({routes, createHistory})(createStore)(reducer);

class Root extends Component {
  render() {
    return (
      <Provider store={store}>
        <ReduxRouter />
      </Provider>
    );
  }
}

render(<Root />, document.getElementById('app'));
/20151225/without-push-state.gif

これに pushState で、 /decr を1秒後に /incr にredirectするという無駄な機能をつける.

-import { reduxReactRouter, routerStateReducer, ReduxRouter } from 'redux-router';
+import { reduxReactRouter, routerStateReducer, ReduxRouter, pushState } from 'redux-router';
+@connect(null, { pushState })
class Decrement extends Component {
+  componentDidMount() {
+    const { pushState } = this.props;
+
+    setTimeout(() => {
+      pushState(null, '/incr');
+    }, 1000)
+  }

  render() {
    return (
      <div>
        <CounterButton>DECREMENT</CounterButton>
        <Link to='/'>
          TO INCREMENT
        </Link>
      </div>
    );
  }
}
/20151225/with-push-state.gif

pushState をRedux Routerから import し、 pushStateconnect の第二引数に { pushState: pushState } の形で渡し、 componentDidMount の中で setTimeout を使って this.props.pushState を呼んでいる.

Redux Routerの pushStateここで定義されていて、どうもAction creatorのようだ.

connect の第二引数って何だろう.

ReduxTutorialconnect解説にこう書いてある.

In most cases you will only pass the first argument to connect(), which is a function we call a selector.

ほとんどの場合、第一引数しか使わないらしい.

仕方ないのでSource codeを読むと、 mapDispatchToProps と呼ぶものらしい.

mapDispatchToProps

もう少し mapDispatchToProps を追ってみる.

connect

connect の定義はここだが、簡単に言うと、4つの引数をとって、1つの引数をとる関数を返す.

export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) {
  return function wrapWithConnect(WrappedComponent) {
    class Connect extends Component {
      render() {
        return createElement(WrappedComponent, this.mergeProps);
      }
    }
  }
}

mapDispatchToProps

Object なら wrapActionCreators でwrapされて、 Object でないならそのままで、 finalMapDispatchToProps に入る. mapDispatchToProps として何も渡さなかった場合は defaultMapDispatchToProps がdefaultで入るようになっている. defaultMapDispatchToProps の定義は、

const defaultMapDispatchToProps = dispatch => ({ dispatch });

となっていて、 dispatch を受け取り { dispatch: dispatch } として返している.

wrapActionCreators

mapDispatchToPropsObject だったときは wrapActionCreators でwrapされるが、 wrapActionCreators の定義はここにあり、 Reduxの bindActionCreators を呼んでいる. bindActionCreators は引数が Object の時は、その values にたいして bindActionCreatormap している. bindActionCreator の定義は、

function bindActionCreator(actionCreator, dispatch) {
  return (...args) => dispatch(actionCreator(...args));
}

で、actionCreatorを与えられた引数で呼んで、 dispatch する関数を返している.

finalMapDispatchToProps

finalMapDispatchToPropscomputeDispatchProps の中で dispatch を引数として呼ばれており、その返り値が Connect#updateDispatchPropsIfNeeded の中で this.dispatchProps に入る. この this.dispatchPropsConnect#updateMergedPropscomputeMergedProps を通して this.mergedProps に入る.

computeMergedProps

computeMergedPropsstateProps, dispatchPropsparentProps を受け取り、 finalMergeProps にそれらを渡し、その返り値を返している.

finalMergeProps

finalMergePropsconnect の第三引数である mergeProps が入っている. connect に第三引数が指定されていない場合は defaultMergeProps が入り、その定義は、

const defaultMergeProps = (stateProps, dispatchProps, parentProps) => ({
  ...parentProps,
  ...stateProps,
  ...dispatchProps
})

で、 stateProps, dispatchPropsparentProps を受け取り、それらをexpandしてまとめて返している. Connect#updateMergedProps でこれが this.mergedProps に入り、最終的に connect の返す関数の引数として渡される WrappedComponentcreateElement で渡される.

createElement(WrappedComponent, this.mergedProps); 

mapDispatchToProps again

結局 mapDispatchToProps は何だったかと言うと、 dispatchcreateElement にどのように渡すかを定義する引数だった.

@connect(null, { pushState })
class Decrement extends Component {
  ...
}

とすると、

createElement(Decrement, { pushState: (...args) => { dispatch(pushState(..args)); } });

となって、 class Decrement の中で this.props.pushState が使えるようになる.

さらに mapDispatchToProps のdefaultが

const defaultMapDispatchToProps = dispatch => ({ dispatch });

であったように、 dispatch 自体もmappingしないと this.props.dispatch は使えないので this.props.dispatch が必要な際は、

functiton mapDispatchToProps(dispatch) {
  return {
    dispatch,
    pushState: bindActionCreators(pushState, dispatch)
  };
}
@connect(null, mapDispatchToProps)
class Decrement extends Component {
  ...
}

と、 dispatch もmappingするような関数 ( mapDispatchToProps ) を作り、 connect の第二引数として渡す.

このように dispatch を内部で呼び出すAPI作る際は、connectmapDispatchToProps を経由して、 this.props に組み込まれるよう作る.

See Also