JavaScript
React
redux

React-Redux超入門(4)

概要

前回までで,Reactで作った簡単なアプリケーションにReduxを導入できた.しかし,いくつか問題があった.そこでここではreact-reduxライブラリを導入して問題点を解決していく.
問題点を整理すると,ReactとReduxは本来無関係であるが,Reactを使ったアプリケーション本体に(当たり前だが)Reduxを利用するためのコードが混じっている(e.g, storeの受け渡しなど).これはモジュールの独立性を高めるという観点からするとよろしくない(tangled concerns).そこでReact-Reduxライブラリを導入することにより,アプリケーション本来のコードからReduxに関するコードを分離する.

準備

react-reduxライブラリのインストール

>npm install --save react-redux

Redux-Reactライブラリの概要

React-Reduxライブラリは,Reduxの存在をアプリケーション本体から分離するために,以下の2つのコンポーネントを提供している(これ以外にも提供していると思われるが,今は割愛).

  • Provider: storeを管理するコンポーネント
  • connect: 分離したstore(ステートで管理するデータ)とアプリケーションを接続するコンポーネント

これだけだとよくわからないので,処理のフローをライブラリ導入前と導入後で比較すると,

導入前:
1. index.jsでstoreの生成
2. storeをAppにプロパティで渡す
3. 子コンポーネント(Input3やItem)にプロパティで渡す
4. 子コンポーネントでstoreを利用する(e.g.,store.dispatch)
5. Reducerが実行される
6. ステートの変更
7. subscribeによる再描画

導入後:
1. index.jsでstoreの生成
2. storeをProviderにプロパティで渡す
3. Providerの子コンポーネントとしてAppを配置する
4. 子コンポーネントでアクションを実行する
5. Reducerが実行される
6. ステートの変更
7. (subscribeによる)再描画

このように,導入後ではstoreをプロパティで渡さなくても何らかの方法で必要なデータをステートから取得したり,アクションを実行できるようになっている.この,何らかの方法というのがProviderconnectである.

では,これまでのアプリケーションにReact-Reduxライブラリを適用していく.

Providerの生成とAppの配置

Providerの生成はindex.jsで行い.storeをプロパティで渡す.今後の改良のため,コンポーネントに相当するファイルはcomponents/xxx.jsのように,componentsディレクトリを生成してファイルを移動している.

index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import appReducer from './reducers/items';
import App from './components/App';

import {createStore} from 'redux';
import {Provider} from 'react-redux'; // (1) Providerのインポート

const store = createStore(appReducer);

function renderApp() {
    ReactDOM.render(
        <Provider store={store}>   // (2)Providerの生成と配置
            <App store={store} />
        </Provider>,
        document.getElementById('root')
    );
}

store.subscribe(()=>renderApp());
renderApp();

(1)
Providerreact-reduxライブラリからインポートする.

(2)
storeProviderにプロパティで渡す.さらに,これまでルートコンポーネントだったAppを,Providerの子として配置する.なお,<App store={store} />のように,Appにもstoreをプロパティで渡しているが,これはこれまでのアプリケーションを動作させるための暫定処理であり,最終的には削除する.

この状態でアプリケーションを実行すると,特に問題なく動作することがわかると思う.そして,この処理で変更後の処理の2と3は終わったことになる.

connectによる接続

まず,React-Reduxライブラリでは,コンポーネントが必要なデータはProps経由で渡すことを基本とする.これまでのアプリケーションでは,this.props.store.getState().xxxのように,Store経由,あるいは個別にProps経由で必要なデータを受け渡していた.これに対し,React-Reduxでは,とある処理をしてコンポーネントに必要なデータをそのコンポーネントのPropsに関連付けて渡すことにより,すべてProps経由で必要なデータ(と関数)を取得できるようにする.
これをできるようにするのがReact-Reduxライブラリが提供するconnect関数である.

connect(mapStateToProps, mapDispatchToProps, mergeProps, options)
  • mapStateToProps: Reduxが管理するステートを受け取り,オブジェクトを返す関数.返したオブジェクトのメンバーは,コンポーネントのProps経由で受け取れるようになる.
  • mapDispatchToProps: Storeのdispatch関数を引数に受け取り,コンポーネントで使う関数をオブジェクトにラップして返す.返したオブジェクトのメンバ-(関数)は,コンポーネントのProps経由で受け取れるようになる.

これ以外の引数(e.g., mergeProps, options)の詳しい解説は本家に任せる.

これらの関数が何をするためにあるのか,初見ではピンとこなかったので詳しく書いておく.
アプリケーション全体で各コンポーネントで行うことは,

  1. どこかからデータを受け取り,コンポーネント自身を再描画する
  2. ユーザのアクションによってイベントを検知し,アクションをReducerに送信する.その際,ActionCreatorを経由してActionを生成する.

ことである.
1に関しては,Props経由で親コンポーネントからデータを受け取るか,自身でステートを持っている場合にはステートから取り出す.今はReduxの使用を前提としているので,アプリケーション全体利用するデータはStoreで管理され,StoreはProps経由受け渡してきていたので,結局のところコンポーネントは必要なデータをPropsからのみ受け取ることになる.

2に関しては,各コンポーネントでActionCreatorをインポートし,store.dispatch(actionCreator(xx))のように,storedispatchメソッドを実行してアクションをReducerに送信していた.storeはこれまでProps経由で渡されてきたので,各コンポーネントとしては必要なActionCreatorをインポートし,Props経由で受け取ったstoreを使ってdispatchを実行していた.

そこで,React-Reduxライブラリでは,storeの存在を隠蔽し,必要なデータはすべてPropsから受け取ることに徹底する.そして,storeの存在を隠蔽するということは,dispatchも隠蔽しなければならない.そこで,dispatchを実行する関数を作成し,その関数をProps経由で受け取る,ということを行う.

これらを実現するために,前者のために存在するのがmapStateToPropsで,後者のために存在するのがmapDispatchToPropsである.こうして考えてみると,これらの関数名が”なるほど”と思える.

では,具体的にこれらの関数を定義し,connectを使って関連付けていく.これまでの説明から,これらの関数は1つのコンポーネントに対して1つづつ用意しなければならない,ということが想像できると思う(決してアプリケーション全体で1つではない).したがって,1つのコンポーネント(を定義するファイル)に対し,1つづつ関数定義とconnectを実行するファイルが存在するほうがわかりやすい.そこで,新たにcontainersをいうディレクトリを生成し,ファイルを整理していく.ここでは,Input3コンポーネントに着目して必要な関数を定義していく.

src/
 ├ index.js // アプリケーションのエントリーポイント.StoreやProviderを生成する
 ├ components/
   ├ App.js // メインコンポーネント.ItemListやInput3などのコンポーネントを含む.
   ├ ItemList.js // アイテムのリストを表現する.
   ├ Input3.js // 入力フォームを生成します.
   ├ Item.js   // 個々のアイテムを表現する.
 ├ containers/
   ├ Input3.js // 新規作成.Input3コンポーネントに必要な関数を定義し,connectを実行します.
 ├ actions/
   ├ item.js // ActionCreatorを定義します.
 ├ reducers/
   ├ item.js // Reducerとステートを初期化します.

containers/Input3.jsの作成

以下の通り,関数定義やconnectを実行するファイルを定義する.

containers/Input3.js
import {connect} from 'react-redux';                 // (1)connectのインポート
import Input3 from '../components/Input3';           // (2)対応するInput3コンポーネントをインポート
import {inputTask, addTask} from '../actions/items'; // (3)アクションをインポート

function mapStateToProps({task, tasks}) {  // (4)mapStatePropsの定義
    return ({
        task,
        tasks
    });
}


function mapDispatchToProps(dispatch) {   // (5) mapDispatchToPropsの定義
    return ({
        inputTask(task) {
            dispatch(inputTask(task));
        },
        addTask(task) {
            dispatch(addTask(task));
        }
    });
}

export default connect(mapStateToProps, mapDispatchToProps)(Input3); // (6)connectの実行

(1)
connect関数をreact-reduxからインポート

(2)
Input3コンポーネントをcomponents/Input3からインポート.後でconnectを使って定義する関数とコンポーネントを接続するときに使用する.

(3)
後述するmapDispatchToPropsで使用するためにActionCreatorをインポート

(4)
この関数で,storeが管理するステートから必要なデータを取り出し,コンポーネントにPropsとして渡す.この関数は引数にstore.getState()の結果(つまりステート)が渡されるので,そこから必要なデータを取り出している.ここでは,

const {task, tasks} = store.getState();

を実行していることになるので,(今回はステートでは2つのデータしか管理していないので同じくなるが)ステートから必要なデータだけを取り出して変数に代入している.その後,これらをメンバにもつオブジェクトを生成し,返している.

return ({
     task,
     tasks
});

JavaScriptのオブジェクトは,

{
  name1: value1,
  name2: value2,
  ...
}

のように,各メンバはラベル:値というHashの形式を取るが,変数名をそのままラベル名に用いる時には上記のような書き方ができる.これは例えば各変数に,task=aaatasks=[]という値が入っていたとすると,以下の定義と同義である.

{
  task: "aaa",
  tasks: []
}

この関数でオブジェクトを返すことにより,対応するコンポーネント,つまり今回はInput3は,Props経由してprops.taskprops.tasksでデータを受け取れるようになる.

(5)
この関数は,ちょっとわかりにくいが,関数をメンバに持つオブジェクトを返す.具体的には,この関数を実行すると,

{
    addTask: Function{}
    inputTask: Function{}
}

というオブジェクトが返ってくる.そして,関数の中身がdispatch(addTask(task))のようになっている.この場合,関数の中身で実行するのはaddTaskであり,これはインポポートしたActionCreatorとなっている.mapDispatchToPropsの引数はstore.dispatch関数であるため,ActionCreatorで生成したActionをdispatch(action)とすることでReducerに通知している.そして,先のmapStateToPropsと同様,mapDispatchToPropsの返り値であるオブジェクトのメンバは,対象コンポーネントからはprops.addTask, props.inputTaskとして参照できる.つまり,コンポーネントでaddTaskという名前で値である関数オブジェクトを参照でき,addTask(xxx)のように関数を実行することができる.
これによって,対象コンポーネントからdispatchの存在を隠蔽できていることになる.
これがReact-Reduxがやりたかったことの1つである.

(6)
ここまで,(4)と(5)の処理で必要なデータや関数をオブジェクトとして返す関数を定義してきた.最後に,これらの関数と対象となるコンポーネントを関連付けるのがconnectである.connectの引数に2つの関数を渡し,返ってきた関数を対象コンポーネントを引数(つまりインポートしたInput3)に実行する.そして,export defaultしているので,このファイル(containers/Input3.js)をインポートした場合.connect()()の返り値をインポートできる.

Appの修正

Input3はこれまで以下のようにしてAppコンポーネントに定義していた.

components/App.js
(省略)
import Input3 from './Input3';

class App extends Component {
  render() {
    return (
        <div>
          <h1>Todo App</h1>
            <Input3 store={this.props.store}/>  // Input3の定義
          <ul>
            <ItemList items={this.props.store.getState().tasks} />
          </ul>
        </div>
    );
  }
}

今回作成した,containers/Input3によって,はProps経由でstoreなどのデータを渡す必要がなくなるため,以下のように修正する.

components/App.js
import React, { Component } from 'react';
import './App.css';

//import Input3 from './Input3';        // 削除
import Input3 from '../containers/Input3'; // (1)新規にインポート
import ItemList from './ItemList';

class App extends Component {
  constructor(props) {
      super(props);
  }

  render() {
    return (
        <div>
          <h1>Todo App</h1>
            <Input3/>                   // (2)
          <ul>
            <ItemList items={this.props.store.getState().tasks} />
          </ul>
        </div>
    );
  }
}

export default App;

(1)
直接コンポーネントをインポートするのではなく,container/Input3.jsでエクスポートされるオブジェクトをInput3という名前でインポートする(ラッパーオブジェクトと言ったりする).

(2)
ラッパーオブジェクトを定義する.storeをProps経由で渡す必要はない.

この変更に伴い,components/Input3.jsも以下のように修正する.

components/Input3.js
import React, {Component} from 'react';
//import {inputTask, addTask} from '../actions/items'; // (1)ActionCreatorの削除

class Input3 extends Component {
    constructor(props) {
        super(props);
        this.addItem = this.addItem.bind(this);
    }

    addItem() {
        this.props.addTask(this.props.task); // (2) props経由でデータや関数を受け取る
    }

    render() {
        return (
            <div>
                <input type="text" 
             onChange={(e) => {this.props.inputTask(e.target.value)}} /> //(3) (2)と同様
                <button onClick={this.addItem}>Add</button>
            </div>
        )
    }
}
export default Input3;

(1)
ActionCreatorはラッパーオブジェクトが実行するので,必要なくなる.

(2)
connectによって関連付けられたデータや関数はProps経由で受け取り,関数を実行している.ここで実行するaddTaskはActionCreatorではなく,mapDispatchToPropsで定義した関数である(くどくてすいません).

前回のアプリケーションにここまでの修正を加えると,無事に実行できることが確認できる.
ここまでの全ファイルを載せておく.

index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import appReducer from './reducers/items';
import App from './components/App';

import {createStore} from 'redux';
import {Provider} from 'react-redux';

const store = createStore(appReducer);

function renderApp() {
    ReactDOM.render(
        <Provider store={store}>
            <App store={store} />
        </Provider>,
        document.getElementById('root')
    );
}

store.subscribe(()=>renderApp());
renderApp();
reducers/items.js
const initialState = {
    task: '',
    tasks: [],
};

function appReducer(state = initialState, action) {
    switch(action.type) {
        case 'INPUT_TASK':
            return {
                ...state,
                task: action.payload.task,
            };
        case 'ADD_TASK':
            const nl = state.tasks.concat([action.payload.task]);
            return {
                ...state,
                tasks: nl
            };
        default:
            return state;
    }
}

export default appReducer;
actions/items.js
export const inputTask = (task) => {
    return ({
        type: 'INPUT_TASK',
        payload: {
            task
        }
        });
}

export const addTask = (task) => {
    return ({
        type: 'ADD_TASK',
        payload: {
            task
        }
    });
}
containers/Input3.js
export const inputTask = (task) => {
    return ({
        type: 'INPUT_TASK',
        payload: {
            task
        }
        });
}

export const addTask = (task) => {
    return ({
        type: 'ADD_TASK',
        payload: {
            task
        }
    });
}
components/App.js
import React, { Component } from 'react';
import './App.css';

import Input3 from '../containers/Input3';
import ItemList from './ItemList';

class App extends Component {
  constructor(props) {
      super(props);
  }

  render() {
    return (
        <div>
          <h1>Todo App</h1>
            <Input3/>
          <ul>
            <ItemList items={this.props.store.getState().tasks} />
          </ul>
        </div>
    );
  }
}
export default App;
components/Input3.js
import React, {Component} from 'react';

class Input3 extends Component {
    constructor(props) {
        super(props);
        this.addItem = this.addItem.bind(this);
    }

    addItem() {
        this.props.addTask(this.props.task);
    }

    render() {
        return (
            <div>
                <input type="text" onChange={(e) => {this.props.inputTask(e.target.value)}} />
                <button onClick={this.addItem}>Add</button>
            </div>
        )
    }
}

export default Input3;
components/Items.js
import React from 'react'

const Item = (props) => {
    return (
        <li>{props.name}</li>
    )
}
export default Item;
components/ItemList.js
import React from 'react';
import Item from './Item';

const ItemList = ({items}) => {
    let list = [];
    for (var i = 0; i < items.length; ++i) {
        list.push(<Item key = {i} name={items[i]} />);
    }
    return list;
}

export default ItemList;

残作業

ここまでで,Input3に関してはReduxと独立させることができた.App.jsをみると,ItemListがまだ独立できていない.また,このせいで実はindex.jsで記述しているstore.subscribe(()=>renderApp());も残す必要がある.完全に独立できていると,この再描画処理をもReact-Reduxライブラリが面倒を見てくれる.

というわけで,やることは

  • containers/ItemList.jsの作成:関数定義とconnectの実行
  • components/App.jsの修正

になる.Input3での作業を踏まえ,変更後のファイルだけ示しておく

containers/ItemList.js
import {connect} from 'react-redux';
import ItemList from '../components/ItemList';

function mapStateToProps({tasks}) {
    return ({
        items: tasks
    });
}

export default connect(mapStateToProps)(ItemList); // ActionCreatorは実行しない
components/App.js
import React, { Component } from 'react';
import './App.css';
import Input3 from '../containers/Input3';
import ItemList from '../containers/ItemList';

class App extends Component {
  constructor(props) {
      super(props);
  }

  render() {
    return (
        <div>
          <h1>Todo App</h1>
            <Input3/>
          <ul>
            <ItemList />  // Reduxから独立
          </ul>
        </div>
    );
  }
}
export default App;
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import appReducer from './reducers/items';
import App from './components/App';

import {createStore} from 'redux';
import {Provider} from 'react-redux';

const store = createStore(appReducer);

function renderApp() {
    ReactDOM.render(
        <Provider store={store}>
            <App/>                 // storeを渡す必要なし
        </Provider>,
        document.getElementById('root')
    );
}

//store.subscribe(()=>renderApp()); // subscribe処理が不要.
renderApp();

以上,React-Reduxライブラリを導入した.これ以外の話題として,画面遷移(ルーティング)やライブラリの使用(非同期処理)がある.それらは次回以降..