React環境にReduxを導入して秩序をもたらした話
初めまして、Webチームのエンジニアの柴山(@shikeapp0909)です。 2017年11月に入社し、Webチームにアサインされ、子育て情報サービスmamanokoの開発をしています。
今回は、今年の2月にmamanokoにReduxを導入した際の考慮したことや設計などについて書きます。
導入前の状態
Reduxを導入する前は素のReactだけで実装していました。 stateの複数コンポーネントでの参照・更新をなるべく避け、stateを渡すのではなくコンポーネントをモノリシックにしてバケツリレーしないようにさせたりすることでなんとか回避している状態でした。
とは言ってもやはりコンポーネントはどんどん肥大化し、コンポーネントの中に複雑な処理が混ざり込み、とてもリーダブル・メンテナブルとは言い難い状態となっていました。
また、実装者によって実装の仕方も違ってきてしまい、どうすべきといった明確な基準がないためコードレビューも難しくなり、品質の担保が困難になっていました。
そこでReduxを導入して複雑化し保守性が低くなっている問題を解決しようということになりました。
導入手順
- Reduxの公式リファレンスを読む
- Reduxの事例を調査し、ベストプラクティスを模索し設計
- 実装してリリース
Reduxの公式リファレンスを読む
まずはやはりReduxについてちゃんと理解しておく必要があるので、公式のリファレンスを熟読しました。 Reduxの元となっているFluxも簡単にではありますが公式を読みました。 Reduxに限った話ではありませんが、このライブラリがどのような問題を解決するために生まれたのか、何をもたらしてくれるのかをしっかり理解しておくことが大事だと思います。
Fluxは、Reactで実装するにあたって問題となる至る所で参照・更新され、管理が煩雑になるstateを、データフローを単一方向にすることで管理しやすくし、処理の見通しをよくするためにFacebookが提唱したアーキテクチャです。 FluxはFacebookが提唱したアーキテクチャの名称で、facebook/fluxはこのアーキテクチャのリファレンス実装です。こちらはReactと併用することを前提にしています。 Fluxと言う時はアーキテクチャのことを基本的に指すことが多いと思います。
ReduxはFluxをシンプルな関数のみで実装したもので、JSアプリケーションの状態管理のためのライブラリです。なので、React以外のフレームワーク/ライブラリでも、もちろん素のJSとも組み合わせて使えます。
詳しいことに関しては、公式を読むか、既に丁寧に解説してくださっている記事がたくさんあるので、そちらを読んでください。
Reduxの事例を調査し、ベストプラクティスを模索し設計
Reduxについて理解ができたところで、実際に他社さんのサービスでどのように使われているのかなどの事例を調査し、どうやって実装するのが一番良いのかを模索しました。 特に気になったのは、ディレクトリ構成とMiddlewareをどうしているかです。
また、弊社アプリはSPAではなく、サーバサイドで生成したビューの一部をReact on RailsでReact化してSSRしています。そのため、SSRの事例を特に調査しました。
ディレクトリ構成
ディレクトリ構成に関しては、action
やreducer
、middleware
などをそれぞれディレクトリを分けるか、ドメインでひとまとめにしてしまうかを考えました。
ちなみにドメインでaction
やreducer
をひとまとめにするパターンはDucksといいます。基本的にaction
は特定のreducer
に紐づくことが多く、その場合action
やreducer
をそれぞれ別のディレクトリ、別のファイルに分ける必要がないため、このパターンは複雑性を回避するには有効な手段だと思います。
しかし、今回は弊社アプリはSPAではなくSSRのまたそのページの一部分でのみReactを使用しているため、既に関心ごとによってディレクトリが分けられている状態でした。そのため、全てをひとまとめにするよりは、どれがaction
で、どれがreducer
かということが明確に分かった方が良いと判断し、Ducksパターンではなく、ベーシックなディレクトリ構成を取ることにしました。
最終的にこのような構成となりました。
〇〇 // add_todo_formなど部品名が入る ├── components // 純粋なReactコンポーネント │ ├── 〇〇.jsx │ └── △△.jsx ├── containers // コンポーネントにReduxをつなぐためのクラス │ ├── 〇〇.jsx // ファイル名・クラス名はコンポーネントと同じ命名とする │ └── provider.jsx // storeとつなぐ一番親となるProviderクラスはprovider.jsxとする └── stores // Redux関連のコード ├── action_types.js // Action名の定数群 ├── actions.js // Actionの定義 ├── reducer.js // Reducerの定義 ├── middleware.js // Middlewareの定義 └── store.js // Storeの定義
ディレクトリ構成に関してはこちらがとても参考になりました。
Middlewareをどうするか
一番頭を悩ませたのがここです。
Redux自体が副作用を必要とする例えば非同期処理などをする手段を用意はしているものの、ほぼ別のライブラリに頼ることを前提としている感じです。公式ではMiddlewareのところでredux-thunkが登場します。正直Reduxについて理解するだけでお腹いっぱいなのに、また別のMiddlewareのライブラリについて理解しないといけない必要が出てくるのはとても大変だし、それによって初心者の方にとっては参入障壁が高く感じられるポイントではないかと思います。
redux には良い middleware が必須 - Togetterでも議論されていますが、Reduxはシンプルでピュアな反面、実サービスにおいて一番肝心な要素となるMiddlewareが完全に別のライブラリに丸投げされていて、Reduxが果たして最適解なのかという状態を生み出している気がします。ReduxがMiddlewareをもっといい感じに面倒見てくれるようにしてくれればいいのに(いい感じとは?という疑問はありつつ)とつくづく思います・・・おかげでMiddlewareライブラリが乱立していて初学者を混乱させていると思います。
事例を色々見てみると、redux-sagaを導入している方が多かったため、redux-sagaを導入しようかとも考えましたが、ES6のgeneratorを使用して作られており、自分としてはまた理解する概念が増えるのは避けたいと思いました。また、弊社のWebチームのエンジニアは皆Redux初心者でレベルもバラバラだったため、学習コストはなるべく低く抑えたいと思い、redux-sagaの導入は取りやめました。
そこで公式のMiddlewareの章を3、4回くらい熟読し、そもそものMiddlewareに対する理解を深めることにしました。そしてライブラリに頼らず、Middlewareは素の状態で書いたらどうかと思い試してみたところ、思いの外難しくなく、多少記述が多く面倒な程度だったため、これで進めることにしました。
詳しい実装については後述します。
SSR
SSRに関しては、こちらの記事がとても参考になりました。
また、React On Railsの公式ドキュメントを読み、公式のサンプルを参考にして、どのようにRailsからReact + Reduxに繋ぎ込むのかを考えました。
React On RailsがReduxもサポートしてくれているおかげで多少設定のための処理を加えるだけで、Railsで扱うオブジェクトをそのままReduxのStoreに直接渡すことができたのでとても助かりました。
こちらも詳しい実装については後述します。
実装
調査と設計が終わったところで実装に移ります。 ここではReduxの登場人物ごとの基本的な実装方針について紹介します。
※便宜上TODOアプリ風な実装例を紹介します。
Action
まずAction Typeについてはお決まりで例のごとく以下のようにしています。
export const FETCH_TODO_REQUESTED = 'FETCH_TODO_REQUESTED'; export const FETCH_TODO_SUCCESS = 'FETCH_TODO_SUCCESS'; export const FETCH_TODO_FAILURE = 'FETCH_TODO_FAILURE';
そしてActionについては、Flux Standard Action(以下FSA)に則って実装するためにredux-actionsを使用しています。
FSAに則ることで、チームで開発する際に人による実装のズレを最大限無くしています。
FSAでは、Actionは必ず下記のように、type
、payload
、meta
、そしてerror
の4つのプロパティのみを使用することを要求しています。
{ type: 'FETCH_TODO_REQUESTED', payload: {}, meta: {}, error: false }
redux-actionsを使用することで、下記のように実装することで簡単にFSAに則ったActionを実装できます。
import {createAction} from 'redux-actions'; import * as actionTypes from './action_types'; export const fetchTodoRequested = createAction(actionTypes.FETCH_TODO_REQUESTED); export const fetchTodoSuccess = createAction(actionTypes.FETCH_TODO_SUCCESS); export const fetchTodoFailure = createAction(actionTypes.FETCH_TODO_FAILURE);
これで、アクションを実行した際にredux-actionsが勝手に上述したJSONの形式でActionを生成してくれます。
さらに、このアクションにErrorオブジェクトを引数として渡すことで、redux-actionsが勝手にerror
をtrue
にした状態のActionを生成してくれます。
これによって、例えばaxios
を使用する際に、エラーハンドリングが簡単になります。
axios.get('~~~') .then(response => store.dispatch(actions.fetchTodoSuccess({todos: response.data}))) .catch(error => store.dispatch(actions.fetchTodoFailure(error))); // errorはErrorオブジェクトなので、そのままActionに渡すだけ
Reducer
Reducerもredux-actionsを使って実装しています。
import {handleActions} from 'redux-actions'; import * as actionTypes from './action_types'; const initialState = { todos: null, isLoading: false, error: null }; export default handleActions({ [actionTypes.FETCH_TODO_REQUESTED]: state => ({ ...state, isLoading: true }), [actionTypes.FETCH_TODO_SUCCESS]: (state, action) => ({ ...state, todos: action.payload.todos, isLoading: false }), [actionTypes.FETCH_TODO_FAILURE]: state => ({ ...state, isLoading: false, error: action.payload.response.data }) }, initialState);
switch文で記述するところを、redux-actionsのhandleActions
を使うことでJSONで記述できるようになります。
Middleware
Middlewareは前述の通りで、特にライブラリに頼らず、素の状態で実装しています。
import * as actions from './actions'; import * as actionTypes from './action_types'; export default store => next => action => { switch (action.type) { case actionTypes.FETCH_TODO_REQUESTED: next(action); axios.get('~~~') .then(response => store.dispatch(actions.fetchTodoSuccess({todos: response.data}))) .catch(error => store.dispatch(actions.fetchTodoFailure(error))); break; case actionTypes.FETCH_TODO_SUCCESS: next(action); ... break; ... default: next(action); break; } }
このように、意外と難しくもなく、switch文の記述が多少面倒なくらいなので、素の状態でも十分使えます。
Store
React On RailsでのStoreは通常のReduxと少し実装の仕方が異なります。 React On RailsではStoreをpropsとlocation(option)を引数にとり、storeを返す関数として実装するように要求しています。 React On Railsのドキュメントの「Multiple React Components on a Page with One Store 」の所に書いてあります。 コードは下記の通りです。
import {createStore, combineReducers, compose, applyMiddleware} from 'redux'; import todoReducer from './reducer'; import todoMiddleware from './middleware'; const composeEnhancer = process.env.NODE_ENV === 'production' ? compose : window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; export default props => { const reducers = combineReducers({ todoReducer }); const enhancer = composeEnhancer( applyMiddleware(todoMiddleware) ); return createStore(reducers, props, enhancer); };
const composeEnhancer = process.env.NODE_ENV === 'production' ? compose : window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
ここでは本番環境以外ではChromeのDevツールからデバッグが行えるように、Redux DevTools extensionの設定を加えています。
Storeを生成したら、React On Railsへの登録を行います。
import todoStore from '~/store'; ReactOnRails.registerStore({ todoStore });
これでサーバ・フロント両方でStoreを使う準備が整いました。
Rails側から直接Storeに値を渡したい時には下記のようにします。
= redux_store 'todoStore', props: { todoReducer: { todos: @todos } }
※弊社アプリではテンプレートエンジンにslimを使用しています。
redux_storeの第一引数は上記でReactOnRails.registerStore
で指定したものと同じ名前をStringで指定します。なのでここではtodoStore
となります。
第二引数はprops: {}
となっており、Storeに登録したい値を渡します。
todoReducer: { todos: @todos } }
としている一番上の階層のキーは、StoreでcombineReducers
で指定しているReducerと同じ名前を指定します。ここではtodoReducer
となります。
そうすると初期ロード時のStoreの状態は以下のようになります。
{ todoReducer: { todos: [ 'hoge', 'fuga' ] } }
ReactでStoreを使う際に例のごとく登場するProvider
でのStoreの渡し方も少し特殊です。
import {Provider} from 'react-redux'; export default () => { const store = ReactOnRails.getStore('todoStore'); return ( <Provider store={store}> <Component /> </Provider> ); }
ReactOnRails.getStore()
でstoreを取得して、それをProvide
のpropsに渡してあげます。
こちらでもReactOnRails.registerStore
で指定したものと同じ名前を指定する必要があります。なのでtodoStore
となります。
これでようやくSSRの準備が完了しました。
Component
ComponentはPresentationalとContainerとで分けています。
Presentational Componentは状態を持たず、propsで受け取ったデータの表示とアクションの実行が役割になります。
const Todos = ({todoApp, actions}) => ( <ul> {todoApp.todos.map(todo => ( <li key={todo.id}> <div> {todo.text} </div> <button onClick={() => actions.deleteTodo({ id: todo.id })}>削除</button> </li> ))} </ul> )
Container ComponentはReduxと繋いでPresentational Componentに必要なデータを流し込むのが役割となります。実装は下記のようになります。
import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import Todos from '../components/todos'; import * as actions from '../stores/actions'; const mapStateToProps = state => ({ todoApp: state.todoReducer }); const mapDispatchToProps = dispatch => ({ actions: bindActionCreators(actions, dispatch) }); export default connect(mapStateToProps, mapDispatchToProps)(Todos);
これで実装については簡単にではありますが全て紹介しました。少しでも実装の助けになればと思います。
その他参考資料
全体的に以下の記事を参考にさせていただきました。かなりよくまとめてくださっているので、React + Reduxで開発する時に大いに助けになると思います。
↓こちらは最近公開された記事で、実装時の参考にはしておりませんが、非常に参考になりました。
所感
私も今回初めてReduxを触ったのですが、やはりReactを使用していてある程度規模が大きくなった場合にReduxを使うと責務がいい感じに分けられるので複雑性が解消され、コードがとても読みやすくなり、保守性も高まると感じました。 チームで開発する際の実装の一貫性も保ちやすくなると思います。
ただ、MobXなども出てきており、Reduxよりも筋の良いライブラリが登場する可能性もまだあるのではないかと思っています。 というより、Reduxがもっと強くなってくれればとつくづく思います・・・
まだ実際にアプリケーションにはほんの一部分にしか導入できていないので、なんとも評価しきれない部分もありますが、Redux導入を進めていく中で問題が生じたらその際にまた考えようかと思います。
We're Hiring
弊社ではWeb / ネイティブアプリエンジニアを募集しております。 ご興味がありましたらお気軽にご連絡下さい! エンジニアの方、ぜひ情報交換しましょう!