Redux Router

Redux Routerの紹介.

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

React.js Advent Calendar 2015でRedux Routerの記事を見かけた気がするが、他所は気にしない.

  1. Redux Router
  2. See Also

Redux Router

About

Redux RouterはReact.jsでde facto standardとなっているRouting libraryの React RouterRedux bindings.

React RouterのRedux bindingsはReact RounterもReduxも抱えているOrganizationのracktが作っている redux-simple-routerもあるが、Redux Routerの方が現時点でGitHubのStar数は多い. (名前の通り、redux-simple-routerの方がよりsimpleで、Redux Routerはfatでcomplexだが機能は多い.)

/20151222/rackt.gif

これはrackt.org. サイトではもっと綺麗にうねうねしてる.

Why

ReduxはStateの管理を容易に (一様に) するが、単体でReact Routerを使用すると、 Application上の重要なStateであるURLがReduxでの管理からはずれてしまう.
Redux RouterによりURLのStateもReduxで管理できる.

Usage

とりあえずReduxでApplicationを作って、そこにReact Routerを導入、最後にRedux Routerを導入する手順で紹介する.

Redux

import React, { Component } from 'react';
import { render } from 'react-dom';
import { combineReducers, createStore } from 'redux';
import { connect, Provider } from 'react-redux';
import { createAction, handleActions } from 'redux-actions';

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({
  counter: handleCounter
});

@connect(state => {
  return {
    counter: state.counter
  };
})
class App extends Component {
  render() {
    const { dispatch, counter } = this.props;
    return (
      <div>
        <p>{`COUNTER: ${counter}`}</p>
        <button onClick={() => { dispatch(incrCounter()); }}>
          INCREMENT
        </button>
        <button onClick={() => { dispatch(decrCounter()); }}>
          DECREMENT
        </button>
      </div>
    );
  }
}

const store = createStore(reducer);

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

render(<Root />, document.getElementById('app'));
/20151222/initial-impl.gif

@connect して state.counter を表示し、その state.counter を増減させるボタンがあるだけのApplicationで、とくに解説することはない.

React Router

mport 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 { Router, IndexRoute, Route, Redirect, Link } from 'react-router';

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({
  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()
class CounterButton extends Component {
  static propTypes = {
    type: PropTypes.oneOf(['incr', 'decr']).isRequired
  }

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

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

class Decrement extends Component {
  render() {
    return (
      <div>
        <CounterButton type='decr'>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 = createStore(reducer);

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

render(<Root />, document.getElementById('app'));
/20151222/second-impl.gif

React Routerを導入して IncrementDecrement をRoutingで分けただけ.
共通で CounterButton をrenderしていて、 this.props.type でボタンがクリックされた時に、 incrCounter()decrCounter() のどちらを dispatch するか分岐している.

this.props.type ではなく、URLというApplicationが持つStateで分岐させたいとする.

 @connect()
 class CounterButton extends Component {
-  static propTypes = {
-    type: PropTypes.oneOf(['incr', 'decr']).isRequired
+  static contextTypes = {
+    location: React.PropTypes.object.isRequired
   }
 
   render() {
     const { dispatch } = this.props;
     return (
       <button
         onClick={() => {
-          if(this.props.type === 'incr') {
+          if(this.context.location.pathname === '/incr') {
             dispatch(incrCounter());
           } else {
             dispatch(decrCounter());
					 }
        }} >
        {this.props.children}
      </button>
    );
  }
}
 class Increment extends Component {
   render() {
     return (
       <div>
-        <CounterButton type='incr'>INCREMENT</CounterButton>
+        <CounterButton>INCREMENT</CounterButton>
         <Link to='/decr'>
           TO DECREMENT
         </Link>
       </div>
     );
   }
 }
 class Decrement extends Component {
   render() {
     return (
       <div>
-        <CounterButton type='decr'>DECREMENT</CounterButton>
+        <CounterButton>DECREMENT</CounterButton>
         <Link to='/'>
           TO INCREMENT
         </Link>
       </div>
     );
   }
 }

static contextTypes を定義して、 this.context.location を使用する.

Application全体のStateの管理に一貫性がなくなった.

Redux Router

一貫性を取り戻すためにRedux Routerを導入する.

-import { Router, IndexRoute, Route, Redirect, Link } from 'react-router';
+import { IndexRoute, Route, Redirect, Link } from 'react-router';
+import { reduxReactRouter, routerStateReducer, ReduxRouter } from 'redux-router';
+import createHistory from 'history/lib/createHashHistory';
 const reducer = combineReducers({
+  router: routerStateReducer,
   counter: handleCounter
 });
-@connect()
+@connect(state => {
+  return {
+    location: state.router.location
+  }
+})
class CounterButton extends Component {
-  static contextTypes = {
-    location: React.PropTypes.object.isRequired
-  }

   render() {
     const { dispatch } = this.props;
 
     return (
       <button
         onClick={() => {
-          if(this.context.location.pathname === '/incr') {
+          if(this.props.location.pathname === '/incr') {
             dispatch(incrCounter());
           } else {
             dispatch(decrCounter());
					 }
         }} >
         {this.props.children}
       </button>
     );
   }
 }
-const store = createStore(reducer);
+const store = reduxReactRouter({routes, createHistory})(createStore)(reducer);
 class Root extends Component {
   render() {
     return (
       <Provider store={store}>
-        <Router routes={routes} />
+        <ReduxRouter />
       </Provider>
     );
   }

上から順に解説する.

  • 色々 import .
  • combineReducersrouter:routerStateReducer がhandleするようset.
  • this.context.locationthis.props.location とできるよう、 @connectlocation:state.router.location をset.
  • this.context.location の代わりに this.props.location を使用.
  • storereduxReactRouter でwrapして、 router のStateを store で管理するようにする.
    • createBrowserHistory を使用して、 <Router history={history} /> をしていた場合は、 createBrowserHistoryreduxReactRouter の第二引数に渡す.
    • <ReduxRouter history={history} /> とはしない.
  • <Router routes={routes} /><ReduxRouter /> で置き換える.

これでReduxでURLのStateも router として管理できるようになった.

秩序を取り戻した. ╭( ・ㅂ・)و

See Also