フロントエンドのProps設計について
こんにちは。
株式会社 CHILLNN という京都のスタートアップにて CTO を担っております永田と申します。
今回の記事では「Props設計について」または「Reactのような宣言的UIフレームワークを利用した、コンポーネント間の依存性を表現する方法」について、一つの指針を定義することを目指します。
TL;DR
- Viewロジックと状態遷移を引き起こす副作用は別々に扱うべき
- ViewロジックはPropsによって、副作用はContextによって、それぞれ依存性を注入すると良い
生じていた課題の整理
弊社では、バックエンドから返すデータを、以前別の記事で書いた通り、フロントエンドの UI の論理構造を強く意識したデータ構造(ViewModel)として表現しています。
フロントエンドは、UI の論理的な構造を反映させたデータを受け取っているため、子コンポーネントに受け渡されるPropsが過不足のないシンプルな形になり、フロントエンドから、必要なデータを変換する責務を剥がし、 UI に特化させることができます。
一方、フロントエンドである程度複雑なアクションを実行する必要がある場合には、この限りではありません。
上記事で言及した方法では、View ロジックで必要なデータのみをPropsを利用することで子コンポーネントに受け渡していくことを意図していますが、ユーザーアクションは、アクションを実行するコンポーネントの直接の親だけでなく、先祖にあたるコンポーネントの状態に影響を与える必要がある場合が存在します。
親も含めた先祖で定義した状態に変更を加えるアクションを、子孫にあたるコンポーネントで実行にするには、何らかの形で callback 関数を注入する必要がありますが、このデータの受け渡しをうまく設計していないと、徐々にコンポーネントの受け取る Props が複雑化していき、保守が困難になっていきます。
この、フロントエンドの複雑さを軽減させるような実装方法は何か考えられないか?
この問いに答えることが本記事の目的になります。
バックエンド実装との比較
フロントエンドで生じていた上記のような複雑性は、弊社のバックエンド実装では一切発生していませんでした。
フロントエンドからの要求がどれだけ複雑化しても、バックエンドでは負債を生じさせることなく自然に吸収することができており、フロントエンドが要求する構造のデータを妥協なく返すことができていました。
その理由を考えていく中で、バックエンドではデータに変更を加えるメソッド(コマンド)とデータを取得するメソッド(クエリ)が、異なる流入経路から、それぞれ全く別のメソッドとして呼び出されているという特徴に思い至りました。
弊社のバックエンドからは、フロントエンドの UI の構造と一致させたデータ構造を返しているので、ViewModel を作成するロジックはフロントエンドと近しい複雑さを有しているはずです。
しかしながら、このロジックの過程で、副作用を発生させるコマンドを呼び出すことを考慮しなくて良いという違いが、認知コストに対して大きな影響を及ぼしているはずだと結論づけました。
ここまでの考察から、フロントエンドにおいて、View ロジックとアクションの間で、異なる実装上のメンタルモデルを定義し、明示的に境界を引くことで複雑性を軽減できるのではないかという仮説を立てました。
これ以降は、フロントエンドでView と Action 間にどのような境界を引くことができるのかについて検討していきます。
フロントエンドはどのように実装されるべきか
React のような宣言的 UI のフレームワークで親から子に対する依存性を注入する方法には、主に以下の 2 通りの方法があります。
- Props で明示的にデータを渡す
- Context を利用して暗黙的に注入する
この二つ、どのように使い分けるべきでしょうか?
この問いはこのままでは少々答えづらいので、問いを少し変えてみます。
「開発者として、フロントエンドをどのように実装していけたら楽か?」
という出発点から考えてみます。
前掲した記事で言及したように、動的な UI をシンプルに表現するためには、コンポーネントの構造と一致したデータ構造を受け取るようにすると、ファイル構造も含めて綺麗に、認知コストを低く保ったまま依存関係を表現できます。
BFF 層から返される ViewModel(もしくは UI 構造に合わせて、フロントエンドで変換したデータ)に従って、Props で必要なデータを受け渡しながらコンポーネントを実装していくことで、UI に関しては難なく実装することができます。
上記のようにViewModelが設計されている場合には、UIを作るのはとても簡単なので、理想的には UI 実装が完了した後に、後から副作用としてユーザーアクションを実装できたら嬉しいです。
では、ユーザーアクションに関しては、そのアクションを実行することによって状態を遷移させたいコンポーネントから、状態遷移を実行する関数を副作用として Context にProvideすることで、経路によらずに依存性を注入してみたらどうでしょう?
UIを表現した構造や、その他の依存関係(= Propsの構造)を一切変更せずに好きな場所でアクションを実行することができるようになりそうです。
アクションの実行に必要な引数は、アプリケーションを触っているユーザーが、そのコンポーネントで表示される情報を元にアクションを行なっている以上、ユーザーアクションを実行するコンポーネントが持っているはずです。
それ以外に必要な引数があれば、ユーザーアクションを行う関数をProvideするコンポーネントが持っているはずなので、関数に対して部分適用(カリー化)を行うことで事前に注入しておくことができます。
結果的に、UIの論理的な構造が反映されたViewModelを使ってUIを構築するアプローチをとった場合、最初に立てた問いの一つの答えは以下のようになりそうです。
- コンポーネントの描画に用いるデータは Props で渡すべき
- コンポーネントの状態を変更するアクションは Context を利用して注入すべき
ルールの意味を再検討する
上記で出てきたルールにはどんな意味があるのでしょうか?
上記のルールを守って実装された特定のコンポーネントを取り出して眺めた時、全てのコンポーネントに対して以下のような特徴を見出すことができます。
- Propsで受け取るデータは、自分以下の階層で描画される全てのデータである
- ProvideしたContextに含まれる関数は、自らが管理している状態を、自分より下位のコンポーネントが持つデータを利用して遷移させるための副作用である
- Contextから呼び出して実行している関数は、自分の受け取ったPropsを利用して、自分の先祖にあたるコンポーネントの状態を遷移させる副作用である
言い換えるなら、次のようにも表現できます。
- Propsには自分と下位のコンポーネントで描画すべき情報しか含まれていない
- Contextには先祖のコンポーネントが実行したい副作用が含まれ、アクションの実行とは無関係なコンポーネントからは秘匿化される
提案したルールが「利用しないPropsを受け取らないことによって、Propsの汚染を防いでいる」ことがわかります。
Propsが、直接の親子関係を表現するものであるのに対し、
Contextは、先祖と子孫の関係を表現することに止まります。
二つの異なる依存関係の表現方法で、Viewロジックとユーザーアクションという異なる責務を担当させることで、論理的な接続の仕方が異なる二つの論理構造を、同一の構造の中に無理なく共存させることができます。
まとめ
あらゆるアプリケーションにおいて言えることですが、クエリメソッドとコマンドメソッドは、似たようなデータを扱っていても、その論理は全く似て非なるものです。
今回の提案では、クエリメソッドの結果としてのViewロジックと、コマンドメソッドを保守性を維持したまま共存させるための方法として、PropsとContextの使い分けを提案しました。
できるなら、異なる論理のものは、異なる構造・論理で表現するようにしましょう。
Discussion