ざっくり React with Redux チュートリアル

  • 192
    いいね
  • 1
    コメント

前回までのまとめ

今回のテーマ

  • そもそも Redux とは?
  • Redux で実装するにあたってのテンプレ・お約束
  • React と Redux をつなぐ

そもそも Redux とは何者なのか?

React State の落とし穴

さて、前回紹介した React Component で使える State は Component 内で、いわゆる「インスタンス変数」のような感じで、ついつい無闇に使いがちになってしまいますが、そうして無闇に State を使うことの落とし穴として最たるものに 他の Component から変更したくなった時に辛くなる という点があります(これは別に React に限ったことではなく、他のフロントエンドフレームワーク(Angular とか Knockout とか Vue とか)でも言えることではないかと思います)。

具体例として、前回作った Root Component の this.state.showBox をもし Box Component 側からも変更したい場合はどのようにすればよいでしょうか?

一つのパターンとして、親コンポーネントから専用の関数を Props で渡し、それを子コンポーネントで実行する、というような方法が挙げられます。

src/components/Root.js
import React, { Component, PropTypes } from 'react';

import Box from './Box';

export default class Root extends Component {
  constructor(props) {
    super(props);
    this.state = {
      showBox: false,
    };
  }

  handleClick() {
    this.setState({showBox: !this.state.showBox});
  }

  // this.state.showBox を変更するだけの関数
  changeShowBox(bool) {
    this.setState({showBox: bool});
  }

  render() {
    const btnName = this.state.showBox ? 'Box非表示' : 'Box表示';
    const boxComponent = this.state.showBox ? (
      <Box changeShowBoxFunc={(bool) => this.changeShowBox(bool)}>Sample Box</Box>
    ) : null;

    return (
      <div className="container">
        <h1>{this.props.title}</h1>
        {boxComponent}
        {/* onClick イベントが呼び出された時に handleClick() を呼び出す */}
        <button className="btn btn-primary" onClick={() => this.handleClick()}>{btnName}</button>
        <div>
          {this.props.children}
        </div>
      </div>
    );
  }
}

Root.propTypes = {
  title: PropTypes.string.isRequired,
  children: PropTypes.any.isRequired,
};
src/components/Box.js
import React, { Component, PropTypes } from 'react';

export default class Box extends Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <div className="panel panel-default">
        <div className="panel-body">
          {this.props.children}
          {/* 親コンポーネントから渡ってきた関数をそのまま実行 */}
          <button className="btn btn-primary" onClick={() => this.props. changeShowBoxFunc(false)}>Close</button>
        </div>
      </div>
    );
  }
}

Box.propTypes = {
  children: PropTypes.any.isRequired,
  changeShowBoxFunc: PropTypes.func,
};

しかし、この方法は更なる問題点を生み出すことになります。

  • さらに多層構造になった時にどうすんの? という問題
    • 層の数だけ Props で受け渡す必要が出てくる
  • この Component の関数、どの Component の、どの State を変えてんの? という問題
    • 上記のような1層くらいであればなんとなくわかるけど、層が増えると State を変更している箇所がパッと見で分かりづらくなる

などなど…つまり、 規模が小さいうちはいいけどデカくなるとやっぱ辛いよねー という どこにでもあるあの問題 といえばなんとなく伝わるかと思います。

そして、これを解消するための(だけじゃないですが…)アーキテクチャが実は昨今話題の、Facebook 社が提唱している Flux と呼ばれるアレ です。

Flux と Redux

この項は個人的見解を含みます

Flux アーキテクチャについての詳しい説明は省きますが、以下の様な特徴があるアーキテクチャとされています。

  • データが1方向にしか流れない
    • ので流れを追いやすい
  • どこで何が行われているかがわかりやすくなる
    • スパゲッティなコードになりにくい
    • (その分コード量は多くなる)

そうして Facebook が Flux を提唱したはいいものの、実は Fb が用意したのは dispatcher と呼ばれる Flux を構成する1要素だけであり、その実装を基にした Flux アーキテクチャを実現するためのライブラリが群雄割拠する状況となってしまっていました。

そこに颯爽と現れたのが Flux アーキテクチャを発展させ、そこそこ実用的な作りで、しかも(React での使用が推奨されてはいるものの)React 以外のフロントエンドライブラリでも使えるという、Flux 系アーキテクチャとしては頭一つ抜け出た感のある Redux というライブラリです。

Redux の特徴は…

  • Store がグローバルに1個
    • すべての State を一つの Store が管理するので見通しが良くなる
      • どこからでも・どの State でも参照・更新ができる
    • Reducer を分割することで、機能単位でのコード分割はしやすい
  • Reducer(State の更新ロジック)に状態を持たせない
    • Reducer がステートレスな関数(Pure Function)
    • 更新ロジックがステートレスになることで、テストがしやすくなる
    • ちなみに Action の部分も状態を持たない

という、Flux とは似て非なるもの、といった感じの仕上がりになっていますね。

Redux でなにか実装してみる

さて、早速ですが Redux を使って実装してみましょう。

例えば、TODOリストのデータ構造を Redux で作るとしたら、どのように作れば良いのでしょう?

注: この項の実装サンプルはほとんど動作テストしてません

Redux 側の実装

実装順にはそれぞれの好みも若干ありますが、基本的には以下の様な順番で実装していくのがいいかと思います。

(1) Action の Type Constants を列挙したもの

TODOリストと呼ばれるものに最低限必要な機能は以下のような感じでしょうか。

  • TODOリストに項目を追加する
  • TODOリストから項目を削除する
  • 指定した項目の実行済みフラグを変更する

すなわち上記が Action の種類にあたり、この3つを Reducer が判別できるような値が必要となるのですが、それを定義するためのファイルを一番最初に作ります。

なお、実装によっては Action 内部に含めてしまっている場合もありますが、後々のことを考えると別に分けておいたほうが良い気がします。

src/constants/Todo.js
export const ADD_TODO = 'ADD_TODO';
export const DEL_TODO = 'DEL_TODO';
export const CHANGE_DID_FLAG = 'CHANGE_DID_FLAG';

この定数の書き方に関して特に決まりはないと思いますが、発行された Action がすべての Reducer 関数を通り Action がどこの Reducer のどの処理に向けられたものかを判別できるようにしなければなりませんので、アプリケーション単位のスコープでユニークである必要があります(大規模になる場合は機能のかたまり単位でネームスペースのようなものを prefix としてつけるとかの工夫があったほうが良さそう)。

(2) Reducer を作る(State の設計)

ここで、TODOリストとして必要な State の構造を固め、先ほど列挙した const をもとに State の更新ロジックを書きます。

src/reducers/Todo.js
import * as Todo from '../constants/Todo';

const initialState = {
  todoList: [],
  id: 0,
  didCount: 0,
};

export default function todo(state = initialState, action) {
  const todoList = [].concat(state.todoList);
  const actionId = action.id;

  switch (action.type) {
    case Todo.ADD_TODO:
      const { name, dueTo } = action.todo;
      const stateId = state.id + 1;
      todoList.push({stateId, name, dueTo, did: false});
      return Object.assign({}, state, {
        todoList,
        id: stateId,
      });
    case Todo.DEL_TODO:
      const filteredList = todoList.filter(item => item.id != actionId);
      return Object.assign({}, state, {
        filteredList,
      });
    case Todo.CHANGE_DID_FLAG:
      const targetIndex = todoList.findIndex(item => item.id == actionId);
      if (targetIndex != -1) {
        return state;
      }

      const flag = action.flag;
      const didCount = flag ? state.didCount + 1 : state.didCount - 1;
      todoList[targetIndex].did = flag;
      return Object.assign({}, state, {
        todoList,
        didCount,
      });
    default:
      return state;
  }
}

まずは import * as Todo from '../contants/Todo' で先ほど作った Action のタイプ定義を呼び出します。

そして、Reducder 関数として todo() を実装しますが、その前に初期 State として const initialState = {...} という定義をしておきます(ここが State の構造に当たる部分ですが、実装を見れば分かる通り、あくまでも初期 State を決めているだけのため、実装によってはここから State が大きく変わってしまう可能性もあります)。

todo() は、前に持っていた State と Action を受け取り、action.type の値に応じてswitch文により更新ロジックを決定します(仮にパターンが少ない場合はif文でも構いません)。

更新ロジックが決定したら、そのロジックに従い新しい State を返します。

ここでポイントとなるのは、 Reducer は状態を持たない という点で、引数として渡ってきた State が Object や Array の場合は 直接書き換えてはいけません。

ので、State が Object の場合は Object.assign() を、Array の場合は [].concat() などを使って新しい Object や Array にしたうえで値の変更を反映して return する必要があります。

Memo: なぜ直接書き換えちゃダメ?

なぜ直接 State を操作してはいけないのか?について簡単に説明しましょう。

Redux は Reducer 関数を実行した後 受け取った State を前後で比較して異なる状態の時のみ 新しい方の State を採用する、という動きになっています。

以下は、Redux が Action を Reducer 関数に通す部分の核となる部分のコードです。

https://github.com/reactjs/redux/blob/63fe8503c1a04487f78284d1abddfd87f537f1e7/src/combineReducers.js#L123-L137

ハイライトしている部分にご注目ください。

var previousStateForKey = state[key] という箇所で、Reducer を実行する前の State を持ってきていて、var nextStateForKey = reducer(previousStateForKey, action) という箇所でその State と発行された Action を Reducer 関数を通しています。

そして、hasChanged = hasChanged || nextStateForKey !== previousStateForKey という部分で前後を比較していますね。

ここで、JavaScript の仕様として オブジェクト型は無条件で参照渡しになる という点があります。

よくわからない人は下のコードを参照してください。

var a = {a: 'aaa', b: 'bbb'};

var b = a;
b.b = 'bbbb';

var c = (function (item) {
  item.c = 'ccc';
  return item;
})(b);

// 下記の console.log() はすべて {a: 'aaa', b: 'bbbb', c: 'ccc'} という結果になる
console.log(a);
console.log(b);
console.log(c);

ということで、Reducer で引数の State を直接変更しちゃダメ…というより、直接変更しても全く意味が無いわけです。

(3) Action を作る

Action は Flux では ActionCreator と呼ばれる箇所のものですが、Redux では前述した通り、こちらもただの関数(の集合体)です。

src/actions/Todo.js
import * as Todo from '../constants/Todo';

export function addTodo(name, dueTo) {
  return {
    type: Todo.ADD_TODO,
    todo: {name, dueTo},
  };
}

export function delTodo(id) {
  return {
    type: Todo.DEL_TODO,
    id,
  };
}

export function changeDidFlag(id, flag) {
  return {
    type: Todo.DEL_TODO,
    id,
    flag,
  };
}

上記のように、type に入った const が Reducer 関数の switch に対応するようなデータを return するようにします。

つまり…

// この Action は…
export function addTodo(name, dueTo) {
  return {
    type: Todo.ADD_TODO,
    todo: {name, dueTo},
  };
}

// Reducer のこの部分に対応している(上記 Action が発行されると、この部分で処理される)
...
    case Todo.ADD_TODO:
      const { name, dueTo } = action.todo;
      const stateId = state.id + 1;
      todoList.push({stateId, name, dueTo, did: false});
      return Object.assign({}, state, {
        todoList,
        id: stateId,
      });
...

というわけです。

これで、Redux 側の実装は一通り終わりました。

React とのつなぎ込み

さて、いよいよ React とのつなぎ込みに入ります。

Redux は先に述べたとおり、React 以外のフレームワークとも適合性がありますが、React の場合は公式でバインディングが用意されており、それにしたがって組み込むだけで React 上で Redux を利用できるようになります。

(1) rootReducer を作る

今回の場合、Reducer がまだ1つしかないため Reducer をまとめる必要性は必ずしもあるわけではありません(まとめなくてもいけます)が、今後複数になることを考えて一応やっておきましょう。

src/rootReducer.js
import { combineReducers } from 'redux';
import todo from './reducers/todo';

const rootReducer = combineReducers({
  todo,
});

export default rootReducer;

(2) Store を作る

先に作った rootReducer を入れる箱となる Store を作ります。

src/store.js
import { compose, createStore } from 'redux';
import rootReducer from './rootReducer';

export default function createFinalStore() {
  const finalCreateStore = compose()(createStore);
  return finalCreateStore(rootReducer);
}

(3) Store を Provider に渡す

この Store を Redux と React とのブリッジの役割をしてくれる Provider に渡します。

src/app.js
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';

import Root from './components/Root';
import createFinalStore from './store';

const store = createFinalStore();

render(
    <Provider store={store}>
      <Root title="react test">
        てすと
      </Root>
    </Provider>,
    document.getElementById('app')
);

(4) connect を使って Component で State へのアクセスと Action 関数が使えるようにする

さらに、Reducer に付いている State と Action 関数を Component から使えるようにします。

これにより、React から Redux の State にアクセスしたり Action 関数を実行できるようになりました。

import React, { Component, PropTypes } from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';

import * as TodoActions from '../actions/Todo';

import Box from './Box';
import PostalCodeAjax from './PostalCodeAjax';

export default class Root extends Component {
  constructor(props) {
    super(props);
    this.state = {
      showBox: true,
    };
  }

  handleClick() {
    this.setState({showBox: !this.state.showBox});
  }

  render() {
    const btnName = this.state.showBox ? 'Box非表示' : 'Box表示';
    const boxTitle = this.state.boxTitle;
    const boxComponent = this.state.showBox ? (
      <Box boxTitleP={boxTitle}>Sample Box</Box>
    ) : null;

    return (
      <div className="container">
        <h1>{this.props.title}</h1>
        boxTitle: <input type="text" onChange={elm => this.setState({boxTitle: elm.target.value})} />
        {boxComponent}
        <button className="btn btn-primary" onClick={() => this.handleClick()}>{btnName}</button>
        <div>
          {this.props.children}
        </div>
        <PostalCodeAjax />
      </div>
    );
  }
}

Root.propTypes = {
  title: PropTypes.string.isRequired,
  children: PropTypes.any.isRequired,
  todo: PropTypes.object.isRequired,
  todoActions: PropTypes.object.isRequired,
};

// state の中に store.js の combineReducers で指定したキーの State が全部入ってくる
function mapStateToProps(state) {
  return {
    todo: state.todo,
  };
}

function mapDispatchToProps(dispatch) {
  return {
    todoActions: bindActionCreators(TodoActions, dispatch),
  };
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Root);

上記の例の場合、State 参照や Action 関数の実行は以下のようにすれば良いです。

// state
const todoList = this.props.todo.todoList;

// action
this.props.todoActions.addTodo('todo', '9am');

// サンプル見てるとよく見かけて使えると思った ES6 的なシンタックス
// 処理で使う state, action をとりあえず出しておくみたいな
const { todo, todoAction } = this.props; 

Ajax

Redux 上で Ajax リクエストをする場合に3つポイントがあります。

  • Ajax リクエストをするのは Action 関数で
  • 状態遷移のために Action 関数を分ける
  • react-thunk という Middleware を入れる

Ajax リクエストをするのは Action 関数で

いざ Ajax リクエストを行うとすると、問題となるのはどこでその処理をやるのか、ということですね。

「Ajax で取ってきたデータを State に入れるんだし Reducer じゃないの?」と思う方もいるかもしれませんが、その場合ロード中から完了…などの状態遷移がしづらくなりますし、あくまでも Reducer は State の状態管理(Object とか Array に要素を入れたり足したり…)というプリミティブな機能を担当するのがベターらしいので、Ajax リクエストは Action 関数の中でやったほうが良いでしょう。

状態遷移のために Action 関数を分ける

さて、Action 関数でやるとは言いましたが、どのようにやればいいのでしょうか?

先に作ったTODOリストの機能拡張で「サーバーにリストをセーブする」という機能を作るとしましょう。

src/constants/Todo.js
export const ADD_TODO = 'ADD_TODO';
export const DEL_TODO = 'DEL_TODO';
export const CHANGE_DID_FLAG = 'CHANGE_DID_FLAG';

export const SAVE_LIST_AJAX_REQUEST = 'SAVE_LIST_AJAX_REQUEST';
export const SAVE_LIST_AJAX_RESULT = 'SAVE_LIST_AJAX_RESULT';
src/reducers/todo.js
import * as Todo from '../contants/Todo';

const initialState = {
  todoList: [],
  id: 0,
  didCount: 0,
  saveLoading: false,
  saveLoaded: false,
  saveResult: false,
};

export default function todo(state = initialState, action) {
  const todoList = [].concat(state.todoList);
  const actionId = action.id;

  switch (action.type) {
    case Todo.ADD_TODO:
      const { name, dueTo } = action.todo;
      const stateId = state.id + 1;
      todoList.push({stateId, name, dueTo, did: false});
      return Object.assign({}, state, {
        todoList,
        id: stateId,
      });
    case Todo.DEL_TODO:
      const filteredList = todoList.filter(item => item.id != actionId);
      return Object.assign({}, state, {
        filteredList,
      });
    case Todo.CHANGE_DID_FLAG:
      const targetIndex = todoList.findIndex(item => item.id == actionId);
      if (targetIndex != -1) {
        return state;
      }

      const flag = action.flag;
      const didCount = flag ? state.didCount + 1 : state.didCount - 1;
      todoList[targetIndex].did = flag;
      return Object.assign({}, state, {
        todoList,
        didCount,
      });
    case Todo.SAVE_LIST_AJAX_REQUEST:
      return Object.assign({}, state, {
        saveLoading: true,
        saveLoaded: false,
        saveResult: false,
      });
    case Todo.SAVE_LIST_AJAX_RESULT:
      return Object.assign({}, state, {
        saveLoading: false,
        saveLoaded: true,
        saveResult: action.result,
      });
    default:
      return state;
  }
}
src/actions/Todo.js
import Axios from 'axios';
import * as Todo from '../constants/Todo';

export function addTodo(name, dueTo) {
  return {
    type: Todo.ADD_TODO,
    todo: {name, dueTo},
  };
}

export function delTodo(id) {
  return {
    type: Todo.DEL_TODO,
    id,
  };
}

export function changeDidFlag(id, flag) {
  return {
    type: Todo.DEL_TODO,
    id,
    flag,
  };
}

export function saveTodoList(data) {
  return dispatch => {
    dispatch(saveTodoListRequest());

    Axios.post('/todo_list', data).then(
      response => dispatch(saveTodoListResult(response.data.result))
    ).catch(
      () => dispatch(saveTodoListResult(false))
    );
  };
}

function saveTodoListRequest() {
  return {
    type: Todo.SAVE_LIST_AJAX_REQUEST,
  };
}

function saveTodoListResult(result) {
  return {
    type: Todo.SAVE_LIST_AJAX_RESULT,
    result,
  };
}

上記の例では

  • saveTodoList() により…
    • リクエストの直前に saveTodoListRequest() を実行し State を変更
    • (Ajax リクエスト)
    • 終了後、結果を受け取り saveTodoListResult() を実行し State を変更
      • ここでは POST リクエストのため、data.result という単純なものを受け取っているが、GET リクエストにより取得したリストを State に入れる、というような処理も同じように可能

という形になっています。ここでポイントとなるのは、「サーバーにリクエストを送っている」というステータスと「終わった(成功・失敗)」というステータスを分けるということです。

上記の場合、リクエスト中の場合は saveLoading == true && saveLoaded == false になっているはずですから、その間はスピナーアニメーションを出す、というような制御が可能になります。

また、このようにして非同期的な処理を Action 関数の中で行うには、dispatch を受け取る関数を返し、その中に実行したい処理を書くようにします。そして、実行したい別の Action 関数を dispatch(saveTodoListRequest()) という形でラップしてやることで別の処理を実行することができます。

redux-thunk という Middleware を入れる

さて、このままだとエラーが出てしまい動きません。

というのは、実は先に出した「dispatch を受け取る関数を返す関数」を Action 関数として使うために Middleware が必要になるためです。

React で Middleware を使う際には Store を作る箇所で適用します。

src/store.js
import { applyMiddleware, compose, createStore } from 'redux';
import Thunk from 'redux-thunk';

import rootReducer from './rootReducer';

export default function createFinalStore() {
  const finalCreateStore = compose(applyMiddleware(Thunk))(createStore);
  return finalCreateStore(rootReducer);
}

これで、Redux から Ajax リクエストできるようになりました。

(追記)Action 関数を分けずに書く

もし一つの Reducer で複数の種類の Ajax リクエストが必要な場合、細かいデータの出し分けとかが必要なければ、こういう感じの共通関数を書けば比較的すっきり書けそう。

function requestPostAjax(url, data, requestFunc, fetchedFunc) {
  return dispatch => {
    dispatch(requestFunc());

    Axios.post(url, data).then(
      response => dispatch(fetchedFunc(true, response.data))
    ).catch(
      () => dispatch(fetchedFunc(false))
    );
  };
}

export function saveTodoList(data) {
  return requestPostAjax(
    '/todo_list',
    data,
    () => {
       return {
          type: Todo.SAVE_LIST_AJAX_REQUEST,
       };
    },
    result, data => {
       return {
          type: Todo.SAVE_LIST_AJAX_REQUEST,
          result,
          data,
       };
    }
  );
}

まとめ

  • Redux とは Flux アーキテクチャを発展させたそこそこ使われてるライブラリ
  • Redux で実装する上でのお作法
    • State Object は直接触っちゃダメ
    • Ajax リクエストは Action 関数で
    • react-thunk を入れて dispatch を受け取る関数を返せば非同期処理もできるぞい
  • React とは react-redux をつかってつなぎ込む
    • Reducer をまとめる
    • Store を作る
    • Store を Provider に渡す
    • Component が State と Action 関数群をまとめて Props として受け取れるように connect する