この記事は株式会社ゆめみ Advent Calendar 2023 | Qiita の 2023-12-21 投稿分です。
React のコンポーネント間の結合度、特に「〇〇的結合」といった段階を使った評価について、私なりにその考え方・用語を React に翻訳してみました。
!結合度を始めとしたソフトウェア工学用語については『プリンシプル オブ プログラミング 3 年目までに身につけたい 一生役立つ 101 の原理原則』(上田勲. 株式会社秀和システム. Kindle 版) を参考にしました。
以下、同書籍からの引用は「上田勲(p. (ページ数))」で済ませます。
解説がクラスベース OOP に縛られていないので、難なく React に合わせて再解釈できました。「ただ動くだけじゃなくて、開発中の混乱を避けるように上手く設計できるようになりたい」という方にオススメします。
React のコンポーネント同士の結合のしかたの制約を考慮に入れてみると、結合度の各レベルにはこのような短い説明を付けられます。
詳しく解説する前に、まずはそのReact 特有の制約がどんなものなのか確認してみましょう。
C 言語では生ポインタが扱えますが、Java では基本的に不可能です。
このように、言語によって実装できること、(特殊な機能を使わないかぎり)実装できないことがあります。「こういうことは書けない」という制限を設けることでロジックの管理しやすさ、バグの起きづらさを狙っていることも多くあります。
言語だけでなくフレームワークによっても異なります(React はライブラリを自称していますが)。 React の関数コンポーネントに注目すると、以下のような2つの制約があります。
これらの制約は、コンポーネント間の結合を疎結合に保ってくれる安全装置であり、結合度の尺度を React 開発に当てはめるためのに必要です。
キーワード:Lift State Up パターン,制御コンポーネント (Controlled Component)
親コンポーネントPage と、その子コンポーネントNumberDisplay とButtons を使った簡単なページの例があります。これを使って React におけるコンポーネント間の直接コミュニケーション手段の制約をまず確認しましょう。
この記事において「親」「子」と言う場合には、Page がNumberDisplay を直接利用している直接の親子関係あるものだけを指すことにします。同じく「親子関係」と呼ばれて混同されやすい、Composition パターンを使用した関係は包含関係と呼んで区別します。
// 🙅♂Cluster - SubmitButton 間は親子関係でないとする<Cluster><SubmitButton/></Cluster>"use client";import{FC, useState}from"react";import{NumberDisplay}from"./number-display";import{Buttons}from"./buttons";constPage:FC=()=>{const[count, setCount]=useState(0);return(<div><NumberDisplayvalue={count}/><ButtonsonIncrement={()=>setCount(count+1)}onDecrement={()=>setCount(count-1)}/></div>);};exportdefaultPage;import{FC}from"react";typeProps={ value:number;};exportconstNumberDisplay:FC<Props>=({ value})=>{return<div>現在の値:{value}</div>;};"use client";import{FC}from"react";typeProps={onIncrement:()=>void;onDecrement:()=>void;};exportconstButtons:FC<Props>=({ onIncrement, onDecrement,})=>{return(<div><buttononClick={onDecrement}>minus</button><buttononClick={onIncrement}>plus</button></div>);};
NumberDisplay のvalue Prop は、Page のcount ステートが(setCount呼び出しで)更新されたとき必ず更新されるButtonコンポーネントのonIncrementは、「インクリメントしたとき」に発火されるイベントハンドラ基本的には、複数コンポーネントが Context 等無しで連携する方法はこの 2 つです。
Context を使った場合の Provider と Consumer (useContext を使う側) の連携も同様の関係になります。
初中級者にありがちな勘違いですが、子コンポーネントの側にステートは必要ありません。Props だけで親のステートに動的に反応できます。
兄弟間の直接コミュニケーションは不可能なので、Lift State Up パターンを使います。(上の図はこのパターンを表しています)
例: plus のボタンをクリック
→onIncrementイベントがButtonsからPage(上向き)に発生
→setCountが呼び出されて、再レンダリングがトリガーされる
→ 新しいcountの値がNumberDisplayのvalueProp に渡されて表示に反映される(下向き)
なので、一般的な手続き的・オブジェクト指向的な複数クラス間の連携よりも厳しい制約が React コンポーネント同士の連携方法に掛かっているのが分かります。
React でコンポーネントの状態を更新する方法は、基本的にuseState(initialValue) の返り値から取り出したset〇〇 関数(セッター関数)のみです。
useReducer の返り値由来のディスパッチ関数も同様のものなので可能です。
// ❌ 更新ができないlet wrongExternalVar=0;constPage:FC=()=>{return(<div><div>{wrongExternalVar}</div><buttononClick={()=>{ wrongExternalVar++;}}> plus</button></div>);};なので、イベントハンドラの中で普通にwrongExternalVar++ とインクリメントしても、それが画面に反映されません。「Svelte と違って不便」と言わないでください
「useState (または useReducer) で宣言したもの以外はステートとして扱われない」のがわかる uhyo さんの記事はこちらです。
https://qiita.com/uhyo/items/6a3b14950c1ef6974024
ただし、useEffect やuseSyncExternalStore を使用した「サブスクリプション」と呼ばれるパターンを使えば可能ではあります。
https://zenn.dev/hatchinee/articles/c075fdef8f54d0
内容結合とは、あるモジュールと他のモジュールが一部を共有するようなモジュールの結合の仕方です。
他モジュール内の外部宣言していないデータを直接参照したり、命令の一部 を共有したりする場合が、これに相当します。
内容結合は、高水準言語を使用したモジュールには見られませんが、アセンブラ言語などを使用したモジュール にはしばしば見られます。
上田勲(pp.271-272)
✅React において「内容結合」は起こりません。
JavaScript ではこういった低水準の操作を行えないので、内容結合は起こりません。(WASM だと無理やり実現できるかもしれない)
共通結合とは、共通域に定義したデータを、いくつかのモジュールが共同使用 するような結合形式です。
共通域の定義データとは、いわゆる「グローバル変数」のことです。
共通結合は、結合度が高く、デメリットが多くあります。
上田勲(p.272)
✅React において「共有結合」とは、Context、生の DOM、ブラウザ機能、またはフレームワーク・ライブラリが提供する各機能を用いて、コンポーネントが間接的に連携する結合形式です。
React の仕組みに翻訳すると上記のようになります。詳しく見てみましょう。
ファイルのトップレベルに書かれた再代入可能なグローバル変数やミュータブルなオブジェクトは、そのままで React コンポーネントから変更を検知できません。詳しくは先ほどの「制約2」で説明しています。
しかし、React には「グローバル的」な機能、「範囲を制限したグローバル」のような機能がいくつかあります。便宜的にこれらによって影響しあうのを共通結合と見なすことにします。
それぞれ見てみましょう。
グローバルっぽくデータを読み書きできる機能には次のようなものがあります。
useSearchParams フック,searhParams Propscookies 機能useFormStatus は Actions と連動するクエリパラメータやクッキーについては、「必ず関数等を通して読み書きする」ようにする方法があります。これで、読み書きに使う情報を(どんなキーを使って情報を読み書きするか)それらの関数等に閉じ込めて整合性を取りやすくなるでしょう。
import{ cookies}from"next/headers";exportconstgetMyName=()=>{returncookies().get("my-name");};exportconstsetMyName=(value:string)=>{cookies().set("my-name", value);};また、「これらの情報を扱う(ページに近い・具体的なセクションの)コンポーネント」と「UI に関心のあるコンポーネント」を分けることでも混乱を防げる可能性があります。
個々の結合が密であっても、場所が限定されていたり、その個数が少ないことでデメリットが軽減できていると言えるでしょう。
ちなみに、useFormStatus は用途が狭く限定されているので、乱用しづらくなっています。
React に特有の機能として、Suspense や Context といったものがあります。これらも共通結合に含めてみました。
Suspense に関連する機能 (use() やstartTransition など) は、<Suspense> ~ </Suspense> に囲われた単位で表示をコントロールできます。
Context を使うことで<Parent><Child /></Parent> のような包含関係のとき、Parent と Child の間での暗黙的なやりとりが可能になります。なので Radix UI Primitives が提供しているような、汎用的で複数要素が連動するタイプのコンポーネント(例: Select)で多用されます。Compound Component と呼ばれるパターンです。
https://www.radix-ui.com/primitives/docs/components/select#anatomy
Suspense を自分で書かずに Next.js App Router で普通にページ内で Susense に関連する機能を使用できます。ルーターによってページ単位の Suspense が自動的に挟み込まれているからです。
Context についても同様です。先のセクションでも述べたuseSearchParams のようなフレームワークによって提供される機能 も Context を通じて提供されています。ほかにも、アプリ全体のあちこちから使用されるライブラリの機能を提供する ためにも使用されます。
(例:TanStack Query のQueryClientProvider,Chakra のChakraProvider)
ここまで説明したように、「共通結合は密結合だから悪!」なのではありません。使うべき場所で適度に使えばとても役に立ちます。
これに対し、共通結合を使えば、データの受け渡しのためのパラメータの指定を回避できます。これはモジュールの作成を容易にし、共通結合の短所を超えるメリットをもたらす場合もあります。現実に、共通結合を採用している場面も多くあります。
上田勲(p.274)
しかし、Props のバケツリレーが煩わしいといって、安易に Context 等に頼るべきではありません。
しかし、そもそも、受け渡しパラメータの数が多いのは、モジュール化が適切でないことが原因の大半です。モジュールを再設計して、管理しているデータの位置を再考することにより、パラメータの数を少なくすることができる場合が ほとんどです。
上田勲(p.274)
特に、React では安易に Context を使わずに、まず次のような方法を取れないか検討すべきです。
特に、UI の細かな状態の管理はそれぞれライブラリを使うことで除去できます。
サーバーから取得したデータやクッキーの読み取り等は、ライブラリや Server Component 等の機能をうまく使って小さな具体的コンポーネントに集める ことができます。(こちらはあまりベストプラクティスやライブラリ機能が出揃っていませんが。)
「実質 Context を使っちゃってるじゃん!」と思われるかもしれませんが、ライブラリ・フレームワークの機能そのものなら、ドメイン知識は持たないので問題ありません。
https://ja.react.dev/learn/passing-data-deeply-with-context#before-you-use-context
https://qiita.com/honey32/items/b9f70f960e891f031b0f
https://qiita.com/honey32/items/4d04e454550fb1ed922c
外部結合とは、外部宣言したデータを共有したモジュール間の結合形式です。
外部宣言した定義とは、例えば、public 宣言された変数のことです。
上田勲(p.274)
✅React において「外部結合」は基本的に不可能です。エスケープハッチとしてref とuseImperativeHandleで実装できます。
React においては、前置きの部分で述べたようにデータの流れの方向が厳しく限定されています。「親から子の情報を読み取る」「子から親の情報を読み取る」「親から子のメソッドを呼ぶ」の 3 パターンはどれも実装不可能です。
Props を通じて「子から親にイベントを通知する」「親から子にデータ更新が伝播する」 の原則に可能な限り従うべきです。そうすれば自然と「レベル4:制御結合」以上の疎結合を達成できます。
「子から、親から Props で渡されたオブジェクトのメソッドを実行する」ことは可能ですが、親自体ではなく親に保存した別のオブジェクトを扱っているので「レベル4」以上と考えて差し支えないでしょう。
ただし、エスケープハッチ(緊急用の手段)も用意されています。Ref を使ったメソッドの呼び出しです。 関数コンポーネントからメソッドを公開するのはuseImperativeHandle フックによって可能になります。
厳しく機能を限定することで安全性・疎結合を保っていながら、このように現実的な問題を解決するための緊急用手段も別で用意してくれているのが React の特徴です。
https://ja.react.dev/reference/react/useImperativeHandle
制御結合とは、呼び出し側のモジュールが、呼び出されるモジュールの制御を指示するデータを、パラメータとして渡す結合形式です。
制御結合では、パラメータの 1 つとしてスイッチ変数を渡し、呼び出されるモジュールがその時に行う機能を指示します。このため、呼び出し側は、呼び出されるモジュールの論理を知っている必要があり、相手をブラックボックス扱いにできず、結合度が強くなります。
上田勲(p.275)
✅React における「制御結合」は、文字通り親コンポーネントが子コンポーネントの制御を指示するデータを Props として渡す結合形式です。
汎用性が極めて高く、繰り返しを避けたい意識が強い場合、またスイッチ引数によって型が変わらない場合には許容されます。
例: ライブラリ「MUI」含まれるButton コンポーネントのvariant Prop
https://mui.com/material-ui/react-button/
しかし、それ以外のケースには、バグを作り込む可能性があるため、汎用的なライブラリのコンポーネントでなければ避けるべきです。(つまりほとんどの場合は避けましょう。)論理的凝集 としてもよく知られたパターンです。
「表示される場所によって内容が微妙に異なるリスト」を実装するときには、以下のように制御結合なコードを書いてしまうことが多いです。
showAuthor のフラグによって、videos の一部のデータを使ったり使わなかったりするので、かなりの複雑さが各ページコンポーネントにまで波及してしまいます。しかもフラグが増えるほどにVideoList の複雑さが増えそうです。
exportconstVideoList=({ videos,// 動画のデータリスト showAuthor=false,// true なら投稿者情報を表示 / false なら非表示})=>{// 中略{videos.map(item=>(<VideoItem key={item.id} author={showAuthor&& item.author} title={item.title}/* ... *//>))}// TOP ページ<VideoListvideos={/**/}showAuthor// このページでは投稿者情報を表示する>// マイページ<VideoListvideos={/* author は不要だから undefined を代入...みたいなことをやる */}// このページでは投稿者情報は非表示>このコードを改善して疎結合にするには、DRY(Don't Repeat Yourself) のことは一旦わきに置いて、思い切って「たまたま似ているだけ」の共通化をやめてしまいましょう。(正直、これをだけで十分にクリーンなアーキテクチャになります)
このコードでは、疎結合だけでなく、ある程度の DRY と両立するためにコンポジション(特にコンパウンド) パターンを使用しています。しかし、難しい場合ば DRY を諦めて、VideoList のようなものを用意せずそれぞれのページで愚直に書いてしまっても良いです。誤った共通化よりは、ほどほどに共通化して深追いしない方がマシです。
VideoList とVideoItem は、CSS の観点だとベッタリ密結合だと思われるかもしれません。しかし、ここは「組み合わせて使うことが明らかになので妥協できる」箇所だと思います。「React では凝集度が重要で、結合度はさほど重要でない」ポイントです。知らんけど。
VideoList のvideos Prop については、ページコンポーネントとの間で「スタンプ結合」に陥っていました。それもついでに解消して、ページとVideoItem の間の「データ結合」にまで改善しています。
exportconstVideoList=({ children})=>{// データは受け取らない。中身の表示のしかただけを制御する// (CSSとかちょっと面倒くさそうですが...)// TOP ページ<VideoList>{videos.map(item=>(<VideoItem key={item.id} author={item.author} title={item.title}/* ... *//>))}</VideoList>// マイページ<VideoList>{videos.map(item=>(<VideoItem key={item.id}/* author は渡さない*/ title={item.title}/* ... *//>))}</VideoList>スタンプ結合とは、共通域にないデータ構造を、2 つのモジュールで受け渡しするような結合形態です。
データ構造の受け渡しは、パラメータを介して行います。
ただし、スタンプ結合の場合、受け渡すデータ構造の一部を使用しないことがあります。不必要なデータまで受け渡しする点が、結合度を少し強くしています。
上田勲(pp.275-276)
✅React における「スタンプ結合」は、不必要なプロパティを含む Props を子コンポーネントが受け取っている結合形式です。
exporttypePostInfo={ title:string; slug:string; hoge:string;};import{FC}from"react";importtype{PostInfo}"./_types/post-info"typeProps={ data:PostInfo;};exportconstPostItem:FC<Props>=({ data})=>{return(<div><div>{data.title}</div><div>slug:{data.slug}</div></div>);};この PostItem では、data.hoge プロパティの値が読み取られていません。受け取った Prop たちの一部を受け取るだけで使っていないのでスタンプ結合になります。
十分に疎結合なので、サイト・アプリの規模・性質によっては問題ないと思います。
普通はわざわざ表示に使われないプロパティを宣言することはありませんが、データの取得・保持に使う型をそのまま使った場合にありがちです。
使用場面が多くて大規模・ページごとのバリエーションが多ければ、少しブラッシュアップの余地があります。次のレベルを見てみましょう。
!結合度とは関係ありませんが、Props の型はフラットだと少しだけ扱いやすいです。React のメモ化機能や useEffect の依存配列はオブジェクトの参照を比較することで「再実行・更新しなくてもよい」判定をしているからです。
データ結合とは、モジュール間のインタフェースとして、スカラ型のデータ要素だけを、パラメータとして受け渡す結合形式です。
相手モジュールをブラックボックス化できるので、結合度は一番弱くなります。
モジュール間の結合は、明確化されたパラメータでデータを受け渡す、データ結合が一番よいとされています。
上田勲(p.276)
✅React における「データ結合」は、不必要なプロパティがないように Props を子コンポーネントが受け取っている結合形式です。
前の節で提示したコードには不要な hoge があったのでそれを無くしました。ついでに Props をフラットにして、さらに「取得データ」の型宣言から切り離して別々の型にしてみました。
疎結合のお陰で実現できる利用の仕方の柔軟さを示すために、1つの要素を追加しました。
import{FC}from"react";typeProps={ title:string; slug:string; author?:{ name:string; id:string;};};exportconstPostItem:FC<Props>=({ title, slug, author,})=>{return(<div><div>{title}</div><div>slug:{slug}</div>{!!author&&(<div>{author.name}<small>{author.id}</small></div>)}</div>);};author Prop に値を設定する / しない によってダイレクトに「著者情報を表示する / しない」をコントロールすることが可能になっています。
// 投稿一覧ページは、著者情報の表示あり{items.map((item)=>(<PostItemkey={item.slug}title={item.title}slug={item.slug}author={{ name: item.author.name, id: item.author.id,}}/>))}// マイページは、著者情報の表示なし{items.map((item)=>(<PostItemkey={item.slug}title={item.title}slug={item.slug}// author は非表示なので指定しない/>))}データフェッチを簡単に表すために React Server Component を使用しています。
import{FC}from"react";import{PostItem}from"./post-item";import{ fetchNewPosts}from"./fetch-new-posts";// 投稿一覧ページは、著者情報の表示ありconstPostsPage:FC=async()=>{const items=awaitfetchNewPosts();return(<div>{items.map((item)=>(<PostItemkey={item.slug}title={item.title}slug={item.slug}author={{ name: item.author.name, id: item.author.id,}}/>))}</div>);};exportdefaultPostsPage;import{FC}from"react";import{ fetchMyPosts}from"./fetch-my-posts";import{PostItem}from"../posts/post-item";// マイページは、著者情報の表示なしconstMyPage:FC=async()=>{const items=awaitfetchMyPosts();return(<div>{items.map((item)=>(<PostItemkey={item.slug}title={item.title}slug={item.slug}// author は非表示なので指定しない/>))}</div>);};exportdefaultMyPage;
このように、
ようなコンポーネントでありながら、使用側(各ページ本体)のコードを一目見れば大体の表示内容の推測が分かりやすく実装できます。
「データの取得側の型定義」と「コンポーネント自体の表現のしかたのバリエーションを示す Props の型定義」を別々にして、スタンプ結合からデータ結合に改善することで、このような恩恵を得られます。
React においては、「子が親のことを知りすぎる」のは密結合になるので避けたほうが良いですが「親が子のことを少し知りすぎる」ことは許容されます。
これは、親コンポーネントの中に子がダイレクトに登場し、子は親との通信を子自身の Props を通じて(親の情報を知らずに)行うからです。
兄弟コンポーネントが連携するには、「子の状態」に見えるものを「親の管理するステート」とみなして、Props として子に渡す必要があることは、制約1: コンポーネント間連携方法の制限を見れば明らかです。
(Context を使わずに)状態を共有したければ、Props を使って親子をデータ結合させる必要があります。 もっと結合度が低い「結合なし」としたくても、これを省略するのは不可能です。

一方で、子コンポーネントは、親コンポーネントを「Props を通じてやりとりできる、モジュールの外の世界」「ブラックボックス」のように扱うべきです。
先程のコード例でPostItem の Props の型を、他の型からの引用ではなく自己完結した宣言にしたことには、「親がどんなデータを取得したか知らずに済む」という利点もあります。
疎結合になるだけではなく、コンポーネントが「変わりやすさ」によってレベル別に分類することは、コード全体の保守性を向上させる効果があります。
イベントハンドラの Props 名を考えてみましょう。onClickPlayMovie のような操作方法の詳細を含めた命名よりも、onPlayMovie のように「ユーザーがやりたいこと」にフォーカスした命名のほうが有利です。
<ToolbaronPlayMovie={()=>alert('Playing!')}onUploadImage={()=>alert('Uploading!')}/>アプリには特定の「やりたいこと」(例: 再生を開始する)に対して複数の操作方法がありえます。ボタンのクリック、ショートカットキー、タッチパネルのスワイプ等のジェスチャーなどです。
そういった「複数の方法」の存在を Toolbar コンポーネントが内側に隠蔽してくれるため、使用する側のコンポーネントとの連携をシンプルにできます。
▼ 以上はこの記事を参考にして、コード一部を引用しています。
https://ja.react.dev/learn/responding-to-events#naming-event-handler-props
こちらも結合度のレベルとは(おそらく)無関係な要素ですが、「情報隠蔽」によって「結合の質」のようなものが改善します。
モジュールが、クライアントが知る必要のない内部の詳細部分を隠蔽すれば、インタフェースが小さくなり、やりとりがシンプルになり、コード全体の複雑性を下げることができます。
クライアントから見ても、余計な情報が見えないため、モジュールの使い方がシンプルになり、使い勝手がよくなります。
また、公開されている部分が少なければ、モジュールの内部に変更を留め置くことができる可能性が高くなります。これにより、コードの変更の 波及を最小限に抑えることができます。
上田勲(pp.121-122)
「中身を固定していない汎用的なダイアログ」のような、汎用性のある(ドメイン知識を持たない)コンポーネントを書く場合には、この「情報隠蔽」原則と「あけすけな子供」原則の間でのトレードオフになります。
!例: Radix Primitive の Dialog コンポーネントは、
Dialog.Root のonOpenChangeDialog.Content のonInteractOutsideの両方のイベントハンドラを利用できます。
前者を使えば十分で、詳細を知る必要なくイベントを受け取って制御コンポーネント的に開閉を制御できます。
後者を使えばevent.preventDefault() を使って「外側をクリックしたときでも閉じないようにする」など細かなカスタマイズが可能です。
設計を工夫すれば、トレードオフの関係にある「汎用性」「カスタマイズ性」を両立できます。
どれだけ Props を頑張っても、CSS は CSS で大変です。
Flexbox, CSS Grid とか、コンテナクエリーとか、min-width: 0 とか、「親の状態によって子の表示が崩れる」のを防ぐためには泥臭く頑張る必要があります。
余白が足りないうまく言語化できないのでこの記事では省略します。
みんな知ってるあのサービスも、ゆめみが一緒に作ってます。スマホアプリ/Webサービスの企画・UX/UI設計、開発運用の内製化支援。Swift,Kotlin,Rust,Go,Flutter,ML,React,AWS等エンジニア・クリエイターの会社です。
フロントエンドエンジニア(React) です。おもに関西型言語を話しています。目的駆動パッケージング過激派です。