フロントエンドではまずプレゼンテーション系を分離する

English version

複雑なアプリケーションのためにストアが肥大化したり乱雑化したりしていたところと付き合ってきて、今後もこれではかなり厳しくなっていくからどうにかしたいという気持ちになった。

この記事ではこれを整理するため、また、他のエンジニアが考える中でも「余計な」検討をせずに済むように、コンポーネントから API アクセスまでの領域に構造を導入することで、それらを分離して絡まりを幾分ほぐせるようにしたい。

問題

中規模までのコンポーネントに関わる設計として、レイヤー化やコンポーネント分類、コンポジションなどのプラクティスは浸透してきている。表示する内容の責務を Presentational Component に完全に任せ Container Component で主としてロジック・データ処理を管理することで、表示内容とロジック・データ処理が絡まることはかなり減った。

一方、コンポーネントから API アクセスまでのやり取りまでの間に、秩序がないと感じることがあった。実際のアプリケーションレベルにもなれば、ロジック・データを Container Component だけでは管理しきれないため Redux + async や MobX などの状態管理ライブラリによるストア層を導入して多くを委譲することになるが、「闇雲」に扱っているとやはりここでも絡まってしまう。この絡まりは、肥大化した後で新規参加者に「指摘」されるまで気が付きにくく、コードを良く知っている人からしても、他者によって変更されている可能性を考慮して見ていくため、どうしても認知負荷が高かった。

「ストア」には種類がある

この絡まりの問題の原因の一つは、(肥大化した後には)本来は別れているべき 2 つの責務が「ストア」として混在しているからだろう。

  • 外部からのデータの取得とそれをキャッシュする責務
    • API にアクセスしてデータを取得し、それを保持すること。リポジトリ(Repository)。
    • API アクセス等の更新操作が無ければ変わることは無い。
  • UI 用の状態を保存・管理する責務
    • ページを跨ぐ状態を持つ(比較的グローバル)ストア
    • ユーザーの UI 操作により任意のタイミングで変わりうる。

例えば、「検索ボタン」を押して検索するような検索ページがあったとして、今表示している検索結果に対応する検索条件データが前者で、ユーザーが弄っている検索フォーム UI の状態が後者となる。
従来的な Redux と非同期処理プラグインを使う構成の場合には、同じ場所で両方を行う場合も多かったように思う。reducer を API アクセス用とページ用とで分割していたら問題ないかもしれないが。

以後、前者を リポジトリ、後者を “UI Store” と呼ぶことにする。

技術的な側面でのストアにも当然複数の選択肢があり、これを上の定義に沿った形で利用していけば良い。適材適所ではあるが、同じ技術の利用も含め自由に組み合わせて使ってかまわないだろう。

  • Hook ベースのストア
    • コンポーネントの中で呼ぶ useStateuseReducer
    • カスタム Hook
    • constate や所謂「オレオレ Redux」などのストア
  • Redux
    • plain
    • 非同期処理プラグイン
  • データアクセス用クラス
  • キャッシュ付き GraphQL ライブラリ
  • Concurrent Mode の Resource

例: Concurrent Mode の Resource をリポジトリとし Hook を “UI Store” とした組み合わせ

プレゼンテーション系[1]を分離する

そこに関心や系の違いがある場合、肥大化し始めたら分割すべきだ。つまり、ストア層が肥大化したと感じたらリポジトリと “UI Store” の間に境界線を引くべきだ。これは新しいことではなく、サーバー等においてプレゼンテーションとそれ以外とを分ける、という基本に忠実になるということだ。お互いを繋ぐインターフェースを除き、プレゼンテーション系で何が行われているかをリポジトリは何も知らないし、プレゼンテーション系は “UI Store” までしか意識せずに済む。

2 つのパターンはどちらが良いというよりは、ありうる実現方法を挙げただけだ[2]。パターン 1 では、“UI Store” がリポジトリとプレゼンテーション系との橋渡し役になる。パターン 2 では、先ほどの例のように Container Component (あるいはそこで呼ばれるステートレスな Hook)がリポジトリから “UI Store” への流れを繋ぐ。

他の分割観点もあるが、この分け方と大きく干渉するやりかたは少ないと見ている。基本としての Dependency Injection をやることが前提にはなるが。
とりあえず初手としてプレゼンテーション系を分けておけば、他の観点で分割したくなった時に困ることは少ないし、考慮すべき問題も 1 つ減っているため問題の難しさが緩和されているはずだ。

  • Rx のようなリアクティブなオブジェクトの利用
    プレゼンテーション系外で行う。系内部は React 等 View 用ライブラリが提供するリアクティブ性だけに頼る。
  • CQRS のように Command/Query を隔離する
    プレゼンテーション系外で行う。系内での CQRS 的データフローとは分ける。
  • 同心円状のレイヤードアーキテクチャ
    プレゼンテーション系外で行う。系はこの外殻の層にあたる[3]
  • フロントエンドでの DDD
    プレゼンテーション系外で行う。おそらく、系はプリミティブ値もしくは(DDD の)ドメインモデルとユースケースに依存するはず[4]
  • 各種のコンポーネントデザインパターン
    プレゼンテーション系に閉じる。(系の内外をまたがったパターンをまだ見たことがない)
  • コンポーネントライブラリ・フレームワーク
    プレゼンテーション系に閉じる。

まとめ

ストアはリポジトリと “UI Store” に分割することができて、後者を含むプレゼンテーション系を最初に分割すると上手くいくだろうと述べた。


  1. 「層」と呼ぶには分厚い対象を意図しているので、「系」としている。単にプレゼンテーション「層」とすると、Presentational Component の周辺のみが対象であるかのように感じるため。 ↩︎

  2. 元々はパターン 1 を念頭に書いていたが、Container Component のレイヤー分割を経てパターン 2 に収束する気がする。 ↩︎

  3. 但しフロントエンドで大事なのはプレゼンテーション系だったりする ↩︎

  4. 実装経験がないためわからない ↩︎