KDE BLOG

バイブス

React + Redux の基本的な使い方

久しぶりにReduxを使ったら使い方をすっかり忘れていました。
改めて勉強しなおしたのでメモしておきたいと思います。

Reduxとは

ReduxはFluxアーキテクチャを拡張してより扱いやすくした状態管理用のライブラリです。
単独でも使えるし、Angularなどの他のフレームワークと組みあわせることも可能ですが、React.jsとの組み合わせが最も相性が良いようです。

Reduxのメリット

通常、Reactのコンポーネント間のデータの受け渡しは props を通じて行われますが、コンポーネントのネストが深くなるとその受け渡しの回数が多くなってきて辛くなります。作り終えてからの修正も大変になってきてしまいます。

しかしReduxを使うと、そのバケツリレー問題が解決されます。
任意のコンポーネントから store と呼ばれるアプリケーション全体の state を保持するオブジェクトにアクセスすることができ、必要に応じてその state を参照できるようになります。またその state を変更することも可能です。

その他にも、下記のようなメリットがあります。

  • メンテナンスがしやすい
    • Redux はコードの設計がある程度決まっているためそのお作法に則って書く必要があります。 そのため全体的に統一された書き方となるため、コード量は増えるかもしれませんが、あとから読みやすいコードとなります。
  • テストがしやすい
    • Redux ではデータの流れが一元化されるのと、純粋な関数で構築されるのが多くとなるため、テストがしやすくなるようです(まだ自分はテスト書いていない)。

3つの原則

Reduxには3つの原則(Three Principlesがあります。

1. 真実の出所は1つ(Single source of truth

アプリケーション全体の状態は1つのStoreの中にある1つのオブジェクトツリーで保持されます。

2. stateは読み込み専用(State is read-only

storeのstateを変更する唯一の方法は、Actionを送る(dispacthする)ことです。
Actionは、何が起きたかを記述するオブジェクトです。

3. 変化は純粋(副作用のない)関数で作られる(Changes are made with pure functions

Actionによってどのように状態ツリーが転換されるか明示するために、純粋なReducerを書きます。
Reducersはただの純粋関数です。前のstateとActionを引数に取り、次のstateを返します。
重要なのは前のstateを更新するのではなく、新しいstateオブジェクトを返す点です。

Reduxの要素

Reduxを使う上での主な登場人物は下記になります。

  • Action
  • Action creator
  • Reducer
  • Store

Action

Actionはアプリケーションからの情報をstoreへ送るためのオブジェクトです。
たとえば「タスクを追加した」、「APIコールした」、「APIレスポンスを受け取った」など、アプリケーションの状態に影響を与えるような情報です。
Actionがstoreに送られることで、storeは新しいstateを作り、そのstateをもとに新しくviewが更新されます。
Actionをstoreに送るためには、store.dispacth() を使います。

Actionは単なるオブジェクトで、必ず type プロパティを持ちます。 type プロパティが実行されるActionの種類を示します。
type プロパティ以外の構造は自由ですが、デファクトスタンダードとして Flux Standard Action が推奨されます。

例えばTODOアプリの場合は下記のようなActionが挙げられます。

// タスクを追加するAction
{
  type: 'ADD_TODO',
  payload: {
    text: 'Reactを学ぶ'
  }
}

// タスクの完了/未完了を切り替えるAction
{
  type: 'TOGGLE_TODO,
  payload: {
    index: 1
  }
}

Action creator

上記Actionをstoreへ送るには下記のようになります。

store.dispatch({
  type: 'ADD_TODO',
  payload: {
    id: 1,
    text: 'Reactを学ぶ'
  }
});

しかし毎回このオブジェクトを手動で入力するわけにはいかないので、Actionを生成する関数を定義します。
それをReduxでは Action creator と呼びます。

/**
 * Action type
 */
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';

/**
 *  Action creator
 */
const addTodo = text => ({
  type: ADD_TODO;
  payload: {
    text
  }
});

const toggleTodo = index => ({
  type: TOGGLE_TODO;
  payload: {
    index
  }
});

上記のように、Action typeはただの文字列ですが、変数化しておくことで使い回しやすくなります。
このAction creatorを使えばさきほどのstore.dispatch()が下記のように書くことができます。

store.dispatch(addTodo('Reactを学ぶ'));

Reducer

Actionは、何かが起きたということを示しますが、その起きたことに対してどのようにアプリケーションのstateを変化させるかは明示していません。 その役割を担うのが Reducer です。

Reducerは、送られてきたActionと、元のstateをもとに、新しいstateを返す純粋関数です。

(previousState, action) => newState

JavaScriptにおいての純粋関数とは「同じ入力値を渡すたび、決まって同じ出力値が得られる」ということのようです。
そのため、下記のことはReducerで絶対にやってはいけません

  • 引数に手を加える
  • 副作用を起こす。例)APIコールやページ遷移
  • 純粋ではない関数を呼び出す。 例)Date.now() や Math.random()

Reducerの例として、公式チュートリアルから一部引用します。
TODOアプリを想定し、stateは下記のような形を想定します。

{
  visibilityFilter: 'SHOW_ALL',
  todos: [
    {
      text: 'Reactを学ぶ',
      completed: true
    },
    {
      text: 'React + Reduxを学ぶ',
      completed: false
    }
  ]
}

Reducerを書きます。

import {
  ADD_TODO,
  TOGGLE_TODO,
  SET_VISIBILITY_FILTER,
  VisibilityFilters
} from './actions'

const initialState = {
  visibilityFilter: VisibilityFilters.SHOW_ALL,
  todos: []
}

function todoApp(state = initialState, action) {
  switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return {
        ...state,
        visibilityFilter: action.filter
      };
    case ADD_TODO:
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            text: action.text,
            completed: false
          }
        ]
      });
    default:
      return state
  }
}

ポイントとしては下記の点があります。

  • stateは書き換えてはいけない
    • 例ではスプレッド演算子を使ってstateのコピーを作成してから新しいstateを作成しています。
  • default のケースでは前のstateを返す
    • すべての不明なActionには前のstateを返すのが重要

さらに実際にはReducerを分割することができます。
今回の例では、visibilityFiltertodos のstateはお互いに依存関係がないので分割することができます。

// todosのみを更新するReducer
function todos(state = [], action) {
  switch (action.type) {
    case ADD_TODO:
      return [
        ...state,
        {
          text: action.text,
          completed: false
        }
      ]
    case TOGGLE_TODO:
      return state.map((todo, index) => {
        if (index === action.index) {
          return Object.assign({}, todo, {
            completed: !todo.completed
          })
        }
        return todo
      })
    default:
      return state
  }
}

// visibilityFilterのみを更新するReducer
function visibilityFilter(state = SHOW_ALL, action) {
  switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return action.filter
    default:
      return state
  }
}

// Reducer合成を行う
function todoApp(state = {}, action) {
  return {
    visibilityFilter: visibilityFilter(state.visibilityFilter, action),
    todos: todos(state.todos, action)
  }
}

それぞれのReducerは、グローバルの状態のうち自身が担当する部分だけを処理します。
そのためstate引数はすべてのReducerで異なり、処理する状態だけが渡されます。
アプリが大きくなったらReducerごとにファイルを分けると、完全な独立性を保つことができます。

combineReducers

Reduxは combineReducers というルートとなるReducerを作成するのに便利な汎用関数を持っています。
これを使うと上記のtodoApp を下記のように書き換えることができます。

import { combineReducers } from 'redux'

const todoApp = combineReducers({
  visibilityFilter,
  todos
});

またキー名を変えることができます。
例えばReducerの名前がうまくstateのキー名にできなかったとしても下記のようにすることで対応できます。

const todoApp = combineReducers({
  visibilityFilter: hogeVisibilityFilterReducer,
  todos  fugaTodosReducer
});

Store

Storeはstateを保持しており、Actionがdispatch(送信)されるとReducerの呼び出しを処理します。
アプリケーションにおいてStoreは1つだけです。

Storeの責務は下記になります。

  • アプリケーションのstateを保持する
  • getState() によるstateへのアクセスを許可する
  • dispatch(action) によるstate更新を許可する
  • subscribe(listener) によりリスナーを登録する
  • subscribe(listener) で返される関数によりリスナー登録解除を処理する
Storeの作成方法

createStore() に、combineReducers() 等で作成したRootReducerを渡すだけです。

import { createStore } from 'redux'
import todoApp from './reducers'
const store = createStore(todoApp)

createStore() の第二引数には初期状態を渡すことも可能です。 サーバー側の処理をクライアント側とマージしたいときなどに使えます。

const store = createStore(todoApp, window.STATE_FROM_SERVER);

これでもうある程度動作確認ができます。
ReducerとAction creatorのテストも書くことが可能です。

import {
  addTodo,
  toggleTodo,
  setVisibilityFilter,
  VisibilityFilters
} from './actions'

// 初期stateのログ
console.log(store.getState())

// stateが更新されるたびにログをとる(subscribe()はリスナーを登録解除するための関数を返す)
const unsubscribe = store.subscribe(() =>
  console.log(store.getState())
)

// ActionをいくつかDispatchする
store.dispatch(addTodo('Learn about actions'))
store.dispatch(addTodo('Learn about reducers'))
store.dispatch(addTodo('Learn about store'))
store.dispatch(toggleTodo(0))
store.dispatch(toggleTodo(1))
store.dispatch(setVisibilityFilter(VisibilityFilters.SHOW_COMPLETED))

// state更新の購読をやめる
unsubscribe();
store.dispatch(toggleTodo(0)); // -> console.logは出ない

f:id:jinseirestart:20190211184802p:plain

Reactと使う

ReduxをReactと使うためには 公式で推奨されている連携プログラムである react-redux をインストールします。

npm install --save react-redux

Reactは基本view部分のみを担当するライブリですので、Storeのstateをもとにコンポーネントを描画します。 そしてコンポーネント内で必要に応じて、ActionをdispatchすればStateが更新されます。
基本的にはこのstateとactionのdispatchの扱いだけ考えれば良さそうです。

コンテナコンポーネントとプレゼンテーショナルコンポーネントとに分ける

react-reduxは、Reactコンポーネントをコンテナコンポーネントとプレゼンテーショナルコンポーネントに分ける構成を推奨しています。

コンポーネントについてまとめると下記のようになります。

コンテナコンポーネント プレゼンテーショナルコンポーネント
目的 どう動くか(データ取得、state更新) どう見えるか(マークアップ、スタイリング)
Reduxの存在 知っている 知らない
データ読み込み Reduxのstateを読む propsから読む
データ変更 ReduxのActionをdispatchする propsからコールバック関数を呼び出す
作り方 React-reduxの connect() で生成 手作業で書く

基本的に、storeとの連携はコンテナコンポーネントが行い、そこからプレゼンテーショナルコンポーネントへpropsで必要なデータを渡すという流れになります。
コンテナコンポーネントはstoreとの連携に注力するため、基本このコンポーネントにjsxを書くのは誤りです。
しかしコンポーネントが小さく、どちらのコンポーネントに分けたら良いか判断が難しい場合は、コンテナコンポーネントにJSXを書いた混合版を作ってもOKです。

また、必ずコンテナコンポーネントの子コンポーネントはプレゼンテーショナルコンポーネントである、というわけでもなく、データの操作の必要に応じて、コンテナコンポーネントの子にコンテナコンポーネントを書いても問題ありません。

公式チュートリアルでは、TODOアプリをこの構成にわけています。
分かりやすいように図解すると下記になります。
赤がコンテナコンポーネント青がプレゼンテーショナルコンポーネントとなります。

f:id:jinseirestart:20190212011022p:plain

プレゼンテーショナルコンポーネントについて

これは普通のReactコンポーネントです。
storeとは別に個別でstateを持ちたいときや、ライフサイクルメソッドを使いたい場合を除いては、クラスコンポーネントを使わずにステートレスファンクショナルコンポーネントで実装するのが推奨されています。

コンテナコンポーネントについて

コンテナコンポーネントは手作業で書くことも可能ですが、react-reduxの connect() で生成するのが推奨されています。 パフォーマンス最適化などが自動でされるためです。

mapStateToProps

connect() を使うためには、mapStateToProps という関数を定義して引数に渡してあげる必要があります。
この関数は Store の state をどのように Props へ変換するか定義する関数です。

上記のコンテナコンポーネントとプレゼンテーショナルコンポーネントを分ける図を見て、コンテナコンポーネントVisibleTodoList について考えてみます。
VisibleTodoList コンポーネントは TodoList コンポーネントへpropsとして、表示する todos を渡す必要があります。
そのためには現在の visibilityFilter の値を見て、todos をフィルタリングしなければいけません。
この処理を mapStateToProps 内で行うと下記のようになります。

const getVisibleTodos = (todos, filter) => {
  switch (filter) {
    case 'SHOW_COMPLETED':
      return todos.filter(t => t.completed)
    case 'SHOW_ACTIVE':
      return todos.filter(t => !t.completed)
    case 'SHOW_ALL':
    default:
      return todos
  }
}

const mapStateToProps = state => {
  return {
    todos: getVisibleTodos(state.todos, state.visibilityFilter)
  }
}

ちなみに、store の state をそのまま全て propsに渡すには、そのまま state を return すればOKです。

const mapStateToProps = state => state;
mapDispatchToProps

コンテナコンポーネントはActionのDispatchも行うことができます。
mapDispatchToProps という関数を定義して、connect() の第二引数に渡すと、propsにdispatch() を使ったコールバック関数を渡すことが可能になります。
(この mapDispatchToProps 関数は引数に dispatch() を持たせることができます)

例えば、先ほどと同様にコンテナコンポーネントである VisibleTodoList から、TodoList コンポーネントへ、onTodoClick というコールバック関数をpropsへ渡します。
このコールバック関数が実行されると、TOGGLE_TODO というActionがdispatch されます。

const mapDispatchToProps = dispatch => ({
  onTodoClick(id) {
    dispatch(toggleTodo(id));
  }
});

※mapDipatchToProps を定義しなかった場合、dispatch() がpropsとして渡されます。
プレゼンテーショナルコンポーネント側でActionを選定したい場合は、この方法を使うと良いのではないでしょうか。

さて、mapStateToProps と mapDispatchToProps の定義が終わったので、VisibleTodoListを作ります。

import { connect } from 'react-redux'

const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList;

このように connect() が返す関数の引数に propsとして値を渡す先のコンポーネントを指定すればOKです。

Provider に store を渡す

コンテナコンポーネントは store にアクセスする必要がありますが、そのための方法として Provider というReact-reduxのコンポーネントを使います。
このコンポーネントはルートコンポーネントを描画するときに storeを propsに指定するだけで、アプリケーション内のすべてのコンテナコンポーネントで storeを利用できるようになります。
※この仕組みは contextAPIというものを利用しているようですが、詳細はまだ追えていません。

import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import todoApp from './reducers'
import App from './components/App'

const store = createStore(todoApp)

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

以上で、React + Redux の基本的な使い方となります。

まとめ

  • Reduxを使うことで、コンポーネント間のデータの受け渡しが簡潔になる
  • Reduxの基本的な流れは、Actionがdispatchされると、storeは 元のstateとActionをもとにReducerを実行して新しい stateを作成し、そのstateをもとに describe される。
  • Action creator と Reducer はただの関数。
  • Reducer は適切に分割、合成することで対象範囲が絞られて管理しやすくなる
  • Reactと使うには react-redux というReact バインディング(連携プログラム)を使う
  • Reactコンポーネントをコンテナコンポーネントとプレゼンテーショナルコンポーネントに分ける
    • コンテナコンポーネントconnect() を使って生成する
      • connect() を使うには、storeを参照してstateから必要な値をpropsとしてプレゼンテーショナルコンポーネントに渡すためにmapStateToProps を定義する
      • mapDispatchToProps を使えば、propsとしてActionをdispatchするコールバック関数を渡すことができる
    • プレゼンテーショナルコンポーネントは基本的にpropsをもとに見た目を作る普通のReactコンポーネント。できるだけステートレスで作る。
  • ルートコンポーネントを描画するときに Provider というReact-reduxコンポーネントを使ってstoreを渡すことで、アプリケーション内のコンテナコンポーネントでstoreを利用できる

参考