2020/05/31 追記
勉強や経験を重ねた結果、この記事を執筆した時より知識が増え、コードの書き方にも変化があります。
サンプルアプリも同様で、以下のプロダクトのコードのほうが、今の自分の考えが反映されていると思います。
追記終わり
2019/07/14 追記
というコメントを頂き、確かに便利そうだったので導入した。
それに合わせてこの記事の内容もアップデートした。
追記終わり
タイトルに書いた組み合わせで SPA を作るときにどのような設計にするのか、現時点での考えを記録しておく。
チュートリアルの次の一歩というか、現実的なアプリを構築する際の基本となる考え方。
状態管理とどう向き合うかが、主要なテーマになっている。
サンプルアプリ
具体的なコードがないと話を進められないので、サンプルアプリを作成した。
ページは2つあり、「会員種別選択ページ」と「商品選択ページ」からなる。
まず「会員種別選択ページ」で、「プレミアム会員」か「通常会員」かを選ぶ。
「プレミアム会員」の場合は「認証コード」が必要で、正しいコードを入れないと先に進めない。
次に「商品選択ページ」で商品を選ぶ。
「プレミアム会員」だと、10%ディスカウントされるだけでなく、選べる商品も増える。
商品を1つ以上選択すると「購入する」ボタンを押せる。
それ以上の細かい説明については、必要に応じて行っていく。
使っている主なライブラリのバージョンは以下の通り。
- react@16.8.6
- react-dom@16.8.6
- redux@4.0.1
- redux-thunk@2.3.0
- react-redux@7.1.0
- reselect@4.0.0
- react-router-dom@5.0.1
- typescript@3.5.2
APIについては、モックサーバーを作るのは面倒だったのでPromise.resolveで擬似的に再現している。
全体像
原則としてReact Hooksを使い、クラスコンポーネントは使わない。
業務でそこそこの規模の SPA を開発しているが、クラスコンポーネントが必要になったことはほぼ無い。
ドメインモデルに相当するものはReduxで管理し、ビューに関する状態はuseStateで管理する。
ちなみに、useReducerは使い所が難しいと思っている。
Reduxを使うのは大仰に思えるケースでuseReducerを使ったことがあるが、却って複雑になって苦しむことになった。Async Actionの対応、ReduxにおけるcombineReducersに相当するものの用意、そういったことを自分で行う必要がある。そしてそうすると、型定義がどんどん面倒になってくる。
無駄に遠回りしているような気がしてくるし、素直にReduxを使っておくのが身のためだと思う。自前で仕組みを用意すると他の開発者が理解しづらくなる、というデメリットもある。
簡単なログインフォームくらいであればuseStateで済むし、useReducerを使うべきケースというのはかなり限定的ではないだろうか。
コンテナコンポーネントは、ルートコンポーネントであるsrc/components/App.tsxのみ。
このサンプルの規模なら当然そうなるが、規模が大きくなっても、コンテナコンポーネントは出来るだけ少なくしたい。
もちろん、コンテナコンポーネントはいくつあってもいいし、コンポーネントツリーのどこにあってもよい。
Reduxの公式ドキュメントにも次のように書かれている。
Emphasizing “one container component at the top” in Redux examples was a mistake. Don't take this as a maxim.
Should I only connect my top component, or can I connect multiple components in my tree?
しかし個人的には、コンポーネントは出来るだけコンテナコンポーネントではなくプレゼンテーションコンポーネントにしておきたい。
コンポーネントを純粋関数にしておいたほうが保守が楽だし、再利用性やテストのしやすさという観点から見ても、Reduxとは接続せずに単にpropsを受け取るだけにしておきたい。
もちろん、「単一のコンテナコンポーネント」に拘泥すると却って煩雑になってしまう(propsバケツリレーの多発など)ので、必要に応じて複数のコンテナコンポーネントを作ってはいくが。
react-reduxのv7.1で導入されたuseSelectorとuseDispatchを使っている。
connectを使うパターンに比べて、かなり直感的にコンポーネントとstoreを接続できる。
TypeScript はとにかく素晴らしい。
SPA 開発の技術選定の記事に対して、「とにかく TypeScript を入れろ」という力強い意見をいくつか頂いた。
SPAならTypeScript絶対に入れたほうがいい。絶対に / 他7件のコメント https://t.co/vQ2WlAlehx “SPA フルリニューアル計画における技術選定や設計思想(2019年2月版) - 30歳からのプログラミング” https://t.co/Hhalr09u7A
— preview state... (@mizchi) 2019年2月24日
素直にそれに従ってみたが、大正解だった。
VSCode の補完機能が優秀だし、ちゃんと型を書いておけば、間違ったコードを書いた時にエラーを出してくれる。これは本当に精神的に楽になる。脳内メモリは有限なので、「自分の頭で考え、注意を向けておかないといけない要素」を減らせるのはかなり助かる。
まだ TypeScript には習熟していないのだが、それでも既に大きな恩恵を受けている。
状態管理
Reduxに関連するコードはsrc/storeディレクトリに入れてある。
ドメインでファイルを分割し、同じドメインのAction CreatorやReducerは一箇所にまとめている。いわゆる「Ducks パターン」。
このパターンを採用したことにそれほど深い理由はない。単に一箇所にまとめたほうが見通しがよいから。
今後「Re-ducks パターン」などを調べて、そっちのほうがよさそうであればそれを採用する予定。
このサンプルではmemberとproductsという2つのドメインがあり、それをsrc/store/index.tsで一つにまとめている。
各ドメインのファイルでは、次の内容を定義している。例はsrc/store/products.tsから紹介している。
- ドメインオブジェクトの型定義
- 例)
Product
- 例)
stateの型定義- 例)
ProductsState
- 例)
ActionTypesの型定義(enum型)- 例)
ActionTypes
- 例)
Action Creatorが発行するActionの型定義Actionを継承する形で定義する- 例)
UpdateSelectedProductIdsAction
- そのドメインの
Actionの集合Reducerの引数の型に使っている- 例)
ProductsActions
Action Creatorの型定義- 例)
UpdateSelectedProductIds
- 例)
stateの初期値- 例)
initialState
- 例)
Action Creator- 例)
updateSelectedProductIds
- 例)
Reducer- 例)
products
- 例)
stateを元に値を計算して返すビジネスロジック(導出項目)- 例)
extractPurchasableProductList
- 例)
- 引数として
stateの情報を必要とするが、Actionを発行するわけではない関数(導出項目?)- 例)
requestPurchase
- 例)
- 引数として
stateを受け取って導出項目を返すセレクタ関数- 例)
productsDerivedDataSelector
- 例)
最後の3つについては、後述の「ビジネスロジック」のセクションで詳しく説明する。
Async Actionの対応はredux-thunkで行っている。
腐敗防止層
Action Creatorは腐敗防止層としての役割も担っている。
具体的には以下の作業を行っている。
src/store/products.tsのfetchProductListを例に説明していく。
- 必要な値を取捨選択する
fetchProductsApiはmerchant_idを返すが、フロント側ではこの値は必要ないので保存しない。
- プロパティ名の調整
- APIからの返り値はスネークケースであることも多いので、ここでローワーキャメルケースに変換する
fetchProductsApiはプロダクトの名前をなぜかnamaeというローマ字で返しているので、nameに変換する
- データ構造などの調整
namaeの例は命名が適切ではないというケースだが、それ以外にも、構造がおかしい、バックエンドの実装を見ないと絶対に意味が分からないようなマジックワードを返している、などのケースも有り得るので、その調整もここで行う。
Action CreatorはAPIからのレスポンスを吸収しているが、APIへのリクエストのための調整も、一箇所に留める。
src/api.tsのpurchaseApiがそれにあたる。フロントエンドが持っているデータを、APIが求める形式に変換する。
APIとの調整はこれらの箇所に封じ込め、それ以外の箇所ではAPIとの調整を意識しないで済むようにする。
どのレイヤーで対応するか予め決めておき、APIの腐敗がフロントエンド全体に漏れ出すのを防ぐ。
ビジネスロジック
Reduxを使う場合、ビジネスロジックはどこに書けばよいのか。
ネットで調べてみたが、特に決まってなさそうである。
Action Creatorという意見が多い印象だけど、Reducerに書いているケースもある。
Reducer派が掲げる最大の理由は恐らく、ビジネスロジックがstateを必要とするから、というものだと思う。
Action Creatorで使える値は、ビューから渡された引数のみ。
だから、ビジネスロジックがたくさんの値を必要とする場合、Action Creatorの引数が増えてしまうし、Action Creatorに渡すためにビューがデータを持たないといけない。
Action Creatorの引数として渡すためだけにビューが多くのデータを持つのは、違和感を覚えてしまう。
Reducerなら、そういう面倒なことをせずにstateにアクセスできる。
しかし、Reducerがロジックを持つのは、本来の責務から逸脱しているように思えてしまう。
それに、ドメインを跨ぐ場合、例えばproductsのReducerがmemberのstateを必要とする場合などは、結局そのための対応が必要になる。
結論としては、Reducerにはビジネスロジックを入れないようにした。
また、上記の「ドメインを跨ぐ場合」については、ReducerもAction Creatorもそういうケースには対応しないようにした。ただこれは、意図してそうしたわけではなく、結果的にそうなった。
意図したのは、「余計な値をstateに保存しないようにする」ということ。
例えば、各商品の価格は保存するが、ディスカウント後の価格は保存しない。
両方を保存してしまうと、値の二重管理のようになってしまう。一方の値が更新されたのにもう一方の値は古いまま、のような事故が起きかねない。
元の価格さえ分かっていればディスカウント後の価格も定まるのだから、わざわざ保存しないようにする。
この「ディスカウント後の価格」のように、他の値から算出される値を「導出項目」という。
では、導出項目のように、stateに保存すべきではない値を使いたい場合は、どのようにすればいいのか。
言い換えれば、目的の値を算出するためのロジックをどこに書き、どのようにそれを呼び出すのか。
これは結構悩ましいというか、現時点では私は答えを持っていない。
いくつかの選択肢が思い浮かぶ。
Reducerでロジックを持つ- これはさっき否定した。
- 値を必要とするコンポーネントのなかで計算する
- 再利用性が乏しく、ロジックが複数の場所に散らばってしまう。
- ビジネスロジックがビューに露出してしまうのは望ましくない。
Vuexのgetterのようなレイヤーを作るstateとは別にgetterを作って、その中にロジックを書くイメージ。- だがこれは、余計な複雑さを抱え込むことになるし、型をつけるのも大変になる。
- ロジックは決められた場所に書き、呼び出しは自由に行う
- 単なる関数として定義して、それを任意の場所で呼び出す。
- 副作用のない純粋関数であることを徹底すれば、破綻しにくいように思う。
- 引数として
stateを受け取り、算出した値を返すセレクタ関数を定義する4の派生形というか、バラバラに書いていた処理を1つの関数にまとめたもの。
結局4で行くことにした。積極的に選んだというより、思いついたなかで一番マシに思えたから。
置き場所は、sr/store/直下の各ドメインのファイル。
src/store/products.tsのextractPurchasableProductListやcalculateTotalPriceがこれにあたる。
「引数としてstateの情報を必要とするが、Actionを発行するわけではない関数」も、同じように扱う。
src/store/products.tsのrequestPurchaseがこれにあたる。
5を使う形に書き換えた。具体的には、src/store/products.tsにproductsDerivedDataSelectorというセレクタ関数を定義した。
サンプルではreselectというライブラリを使っているが、設計という観点から見たときに重要なのはそこではなく、「stateを引数として受け取り、導出項目のセットを返す関数」を定義してそれを利用することが本質。
requestPurchaseもproductsDerivedDataSelectorの返り値に含めるようにした。
関数を「導出項目」のように扱うのには違和感があるが、requestPurchaseだけ扱いを変えると余計な複雑さが生まれそうなので、まとめることにした。
コンポーネント
ルートコンポーネントはsrc/components/App.tsx。
このコンポーネントが、Redux Hooksを使ったstoreとの接続や、react-routerによるルーティングなどを、行っている。
もっと規模が大きくなってきたら、ルーティングについては分離させたほうがいいと思う。
productsDerivedDataSelectorによる導出項目の取得も、ここで行っている。
他には、useFetchApiを定義している。
これは、コンポーネントのマウント時にAPIを叩くためのCustom Hooks。
マウントした時にのみデータ取得のためのAPIを叩く、という本当によくある処理。その処理を全てのコンポーネントで書くのは無駄なので、useFetchApiとしてまとめた。
ルーティングのためにURLを定数として持っているので、それを使うことで綺麗に書けたと思う。
全てのコンポーネントはFunctional Componentであり、前述のようにReact Hooksを使っている。useStateとuseEffectがあれば、基本的な機能は実装できるはず。