ReactReduxを学ぶために、開発環境というかプロジェクトテンプレートをスクラッチから作っている。 (最終的な成果はGitHubに置いた。)

前回はMaterial-UIをセットアップした。

(2018/11/21更新)

Reactの状態管理

Reactによるプログラミングをするとき、小さいUIコンポーネントをたくさん作って、それらを組み合わせてVirtual DOMツリーを作っておいて、そこにpropsをほうりこんでレンダリングする、という感じになる。 また、レンダリングした後はコンポーネントのstateをいじって状態を変化させる。

このpropsやstateの扱いをReactの状態管理という。 propsやstateを適当にアドホックに設定してると、結局jQuery使ってるのとそんなに変わらなくなって辛くなるので、Reactの開発元であるFacebookはFluxというアーキテクチャを提案している。

Flux


Fluxでは、単一の(またはドメイン毎くらいの単位の)オブジェクトでアプリケーション全体の状態(state)を表し、これをStoreに保持する。 ReactはStoreが保持するstateを受け取り、それをもとにViewをレンダリングする。 Viewに対するユーザの操作(など)はActionというオブジェクトで表現され、Dispatcherに渡され、Dispatcherに登録されたcallbackを通してstateを変化させる。

データが常に一方向に流れて見通しがよく、各コンポーネントの独立性が高いのが特徴。 各コンポーネントは、受け取ったデータをピュアに処理すればよく、リアクティブにファンクショナルに実装できる。

Redux

Fluxの実装、というか発展形がRedux。

ReduxではFluxのDispatcher辺りがReducerに置き換わっている。 ReducerはActionと現在のstateから次のstateを計算する純粋関数。

また、ReduxからはViewが切り離されていて、Actionによってstateを更新する状態管理ライブラリの役割に徹している。 ReactコンポーネントのイベントハンドラからActionオブジェクトを生成したり、更新したstateをReactに渡したりするつなぎ目は、別途React Reduxというライブラリが担当する。

ReduxとReact Reduxについては、Qiitaの「たぶんこれが一番分かりやすいと思います React + Redux のフロー図解」という記事が分かりやすい。

今回はReduxを導入する。

$ yarn add redux

Redux v4.0.1が入った。

以降、現時点で唯一のUIコンポーネントであるHOGEボタンの状態管理を実装してみる。

Action

まずActionを実装する。

Actionオブジェクトはどんな形式でもいいけど、普通はFlux Standard Action(FSA)にする。 FSAは以下のプロパティを持つプレーンオブジェクト。

  • type: Action種別を示す文字列定数。必須。
  • payload: Actionの情報を示す任意の型の値。任意。
  • error: Actionがエラーを表すものかを示す boolean プロパティ。エラーなら true にして、payload にエラーオブジェクトをセットする。任意。
  • meta: その他の情報を入れる任意の型の値。任意。

Actionのコードは、Actionのtypeに入れる値を定義するactionTypes.jsと、Action Creator(i.e. Actionオブジェクトを生成する関数)を定義するactions.jsからなり、ともにsrc/actions/に置く。

HOGEボタンをクリックしたときのAction、HOGE_BUTTON_CLICKEDを定義してみる。

src/actions/actionTypes.js:

export const HOGE_BUTTON_CLICKED = 'HOGE_BUTTON_CLICKED';

src/actions/actions.js:

import {
  HOGE_BUTTON_CLICKED,
} from './actionTypes';

export function hogeButtonClicked() {
  return {
    type: HOGE_BUTTON_CLICKED,
  };
}

こんな感じ。

Reducer

次はReducer

Reducerは、上記Action Creatorが生成するActionオブジェクトに対応して起動し、Store(後述)から現在のstateオブジェクトを受け取って、Actionオブジェクトのpayloadの値(など)に応じて新しいstateオブジェクトを作る。

Reducerを書く前に、stateオブジェクトの構造を設計しておくことが推奨されている。 UIコンポーネント毎にプロパティを分けて、コンポーネント構造と同様の階層構造にしておけばだいたいよさそう。

HOGEボタンに一つ、クリックしたかどうかの状態(clicked)を持たせるとすると、stateオブジェクトは以下のようになる。

{
  hoge: {
    clicked: false,
  },
}


Reducerはピュアじゃないといけないので、内部で[副作用](https://ja.wikipedia.org/wiki/%E5%89%AF%E4%BD%9C%E7%94%A8_(%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0) を起こしてはいけない。 副作用とは、具体的には以下のようなもの。

  • 引数で与えられたオブジェクトを変更する。
  • REST APIへのリクエストを送る。

(ログの出力も厳密には副作用なんだろうけど、それは許されてる気がする。)

また、ピュアであるためには参照透過性を持たないといけなくて、つまり同じ引数に対しては同じ戻り値を返さないといけないので、内部でDate.now()とかMath.random()とかを呼ぶのもダメ。


Reducerのコードはsrc/reducers/に置く。

HOGE_BUTTON_CLICKEDが発生したら、hogeclickedtrueにするReducer(hoge())は以下の感じに書ける。

src/reducers/reducers.js:

import { HOGE_BUTTON_CLICKED } from '../actions/actionTypes';

const initialState = {
  hoge: {
    clicked: false,
  },
};

export const hoge = (state = initialState, action) => {
  switch (action.type) {
    case HOGE_BUTTON_CLICKED:
      const newHoge = {
        hoge: {
          clicked: true,
        },
      };
      return Object.assign({}, state, newHoge);
    default:
      return state;
  }
}

hoge()のポイントはたくさんある。

  • stateactionを引数に取る。前者が現在の状態を表すstateオブジェクトで、後者がActionオブジェクト。
  • 戻り値は新しい状態を表すstateオブジェクト。
  • actionオブジェクトはどのActionを表すものかは分からないので、action.typeを見てHOGE_BUTTON_CLICKEDだけを処理するようにする。
    • 知らないActionだったら(i.e. default句のなかに来たら)、受け取ったstateオブジェクトをそのまま返す。
  • アプリケーションの初期化時にはstateundefinedが渡されるので、それに備え、初期状態であるinitialStateをデフォルト引数に設定する。
  • 渡されたstateオブジェクトを変更してはいけないので、Object.assgin()に空オブジェクト{}とともにstateを渡してコピーする。
  • Object.assign()の第三引数にnewHogeで上書きするようにしている。
    • 今はstateオブジェクトのプロパティがhoge一つだけなので単にnewHogeをreturnしても結果は一緒。なので無駄なことをしてるようにも見えるけど、stateオブジェクトのプロパティが増えた場合にhoge以外に影響を与えないための計らい。


これはこれでいい感じに見えるけど、hoge()hogeプロパティしか扱わないのに、stateオブジェクト全体を渡しているのがイケていない。 (まあ今はstateオブジェクトにはhogeプロパティしかないんだけど、他のプロパティが色々増えてくるとイケてない感が高まる。) hogeプロパティがstateオブジェクト構造のどこにあるかをhoge()が気にしないといけないのもイケてない。 hoge()にはhogeプロパティだけを見てほしい。

ということで、普通はReducerは分割して書いて、それぞれのReducerにstateオブジェクトを分割して渡してやる。

src/reducers/reducers.js:

 import { HOGE_BUTTON_CLICKED } from '../actions/actionTypes';

-const initialState = {
-  hoge: {
-    clicked: false,
-  },
-};

-export const hoge = (state = initialState, action) => {
+export const hoge = (state = { clicked: false }, action) => {
   switch (action.type) {
     case HOGE_BUTTON_CLICKED:
       const newHoge = {
-        hoge: {
-          clicked: true,
-        },
+        clicked: true,
       };
       return Object.assign({}, state, newHoge);
     default:
       return state;
   }
 }

+export const rootReducer = (state = {}, action) => {
+  return {
+    hoge: hoge(state.hoge, action),
+  }
+}

こんな感じで、rootReducerがstateオブジェクトを分割して子Reducerを呼び出す。 孫Reducerとか曾孫Reducerとかがあってもいい。


rootReducerは別のファイルに書くと見やすくなるし、ReduxのcombineReducers()というヘルパー関数を使うともっと楽に書ける。 上記reducers.jsからはrootReducerを削除して、rootReducer.jsに以下のように書く。

src/reducers/rootReducer.js:

import { combineReducers } from 'redux';
import hoge from './reducers';

const rootReducer = combineReducers({
  hoge,
});
export default rootReducer;

このようにcombineReducers()で作ったrootReducerは、上で自前で書いたrootReducerと全く同じ動きをする。

さらに簡単に、以下のようにも書ける。

src/reducers/rootReducer.js:

import { combineReducers } from 'redux';
import * as reducers from './reducers';

const rootReducer = combineReducers(reducers);
export default rootReducer;

こうしておけば、Reducerの追加はreducers.jsに関数を追加するだけでよくなる。

redux-actionsを使うとさらに記述を簡略化できるみたいだけど、逆に何が何だか分からなくなりそうだったので、慣れるまでは使わないでおく。

Store

Storeは以下のような特徴を持つオブジェクト。

  • getState()でstateオブジェクトを返す。
  • dispatch(action)でActionをディスパッチできる。
  • subscribe(listener)でActionのディスパッチをサブスクライブできる。

StoreはrootReducerをcreateStore()に渡すことで作れる。 createStore()を呼ぶコードはモジュールにしておくのがいい。 後で膨らんでくるので。

src/configureStore.js:

import { createStore } from 'redux';
import rootReducer from './reducers/rootReducer';

export default function configureStore(initialState = {}) {
  const store = createStore(
    rootReducer,
    initialState,
  );
  return store;
}

これだけ。


以上でReduxのコンポーネントが一通りそろって、状態管理システムができた。 試しに動かしてみる。

src/try.js:

import { hogeButtonClicked } from './actions/actions';
import configureStore from './configureStore';

const store = configureStore();
console.log(store.getState()); // => { hoge: {clicked: false} }

store.subscribe(() => {
  console.log(store.getState());
});

store.dispatch(hogeButtonClicked()); // => { hoge: {clicked: true} }

store.dispatch()するとReducer(hoge())が実行され、stateオブジェクトが更新されることが分かる。


次回は、今回作ったStoreをReactコンポーネントにつなぐ。