Reactコンポーネント同士の結合度を考える
この記事は株式会社ゆめみ Advent Calendar 2023 | Qiita の 2023-12-21 投稿分です。
React のコンポーネント間の結合度、特に「〇〇的結合」といった段階を使った評価について、私なりにその考え方・用語を React に翻訳してみました。
React のコンポーネント同士の結合のしかたの制約を考慮に入れてみると、結合度の各レベルにはこのような短い説明を付けられます。
-
レベル1: 内容結合
―― 高水準言語なので起こらない -
レベル2: 共通結合
―― グローバル or Context. 賢く使おう -
レベル3: 外部結合
―― React では原則として禁止 -
レベル4: 制御結合 (ここからがマシな結合)
―― 論理的凝集におちいるので注意 -
レベル5: スタンプ結合
―― 無駄なデータにだけは注意 -
レベル6: データ結合
―― 理想的 - 番外編: 名前や型付けで気をつけること
- 「あけすけな子供」原則
- 「親の心子知らず」原則
- イベントハンドラの命名と情報隠蔽
- 番外編: CSS
詳しく解説する前に、まずはそのReact 特有の制約がどんなものなのか確認してみましょう。
密結合・疎結合の前に React のルールを知ろう
C 言語では生ポインタが扱えますが、Java では基本的に不可能です。
このように、言語によって実装できること、(特殊な機能を使わないかぎり)実装できないことがあります。「こういうことは書けない」という制限を設けることでロジックの管理しやすさ、バグの起きづらさを狙っていることも多くあります。
言語だけでなくフレームワークによっても異なります(React はライブラリを自称していますが)。 React の関数コンポーネントに注目すると、以下のような2つの制約があります。
- コンポーネント間の直接コミュニケーションする手段に制約がある
- 「ステート」の範囲が厳密に決まっている
これらの制約は、コンポーネント間の結合を疎結合に保ってくれる安全装置であり、結合度の尺度を React 開発に当てはめるためのに必要です。
制約1: コンポーネント間連携方法の制限
キーワード: Lift State Up パターン, 制御コンポーネント (Controlled Component)
親コンポーネント Page
と、その子コンポーネント NumberDisplay
と Buttons
を使った簡単なページの例があります。これを使って React におけるコンポーネント間の直接コミュニケーション手段の制約をまず確認しましょう。
"use client";
import { FC, useState } from "react";
import { NumberDisplay } from "./number-display";
import { Buttons } from "./buttons";
const Page: FC = () => {
const [count, setCount] = useState(0);
return (
<div>
<NumberDisplay value={count} />
<Buttons
onIncrement={() => setCount(count + 1)}
onDecrement={() => setCount(count - 1)}
/>
</div>
);
};
export default Page;
子コンポーネント群 app/counter/number-display.tsx と app/counter/buttons.tsx
import { FC } from "react";
type Props = {
value: number;
};
export const NumberDisplay: FC<Props> = ({
value
}) => {
return <div>現在の値: {value}</div>;
};
"use client";
import { FC } from "react";
type Props = {
onIncrement: () => void;
onDecrement: () => void;
};
export const Buttons: FC<Props> = ({
onIncrement,
onDecrement,
}) => {
return (
<div>
<button onClick={onDecrement}>minus</button>
<button onClick={onIncrement}>plus</button>
</div>
);
};
-
⬇ 親から子…灰色 親ステート・Props の変更が、子の Props を通じて子にそのまま伝播する
-
NumberDisplay
のvalue
Prop は、Page
のcount
ステートが(setCount
呼び出しで)更新されたとき必ず更新される
-
-
⬆ 子から親…黒 「〇〇したとき」に子で起きたイベントを、親に書いたイベントハンドラで処理する
-
Button
コンポーネントのonIncrement
は、「インクリメントしたとき」に発火されるイベントハンドラ
-
基本的には、複数コンポーネントが Context 等無しで連携する方法はこの 2 つです。
Context を使った場合の Provider と Consumer (useContext
を使う側) の連携も同様の関係になります。
兄弟間の直接コミュニケーションは不可能なので、Lift State Up パターンを使います。(上の図はこのパターンを表しています)
例: plus のボタンをクリック
→onIncrement
イベントがButtons
からPage
(上向き)に発生
→setCount
が呼び出されて、再レンダリングがトリガーされる
→ 新しいcount
の値がNumberDisplay
のvalue
Prop に渡されて表示に反映される(下向き)
なので、一般的な手続き的・オブジェクト指向的な複数クラス間の連携よりも厳しい制約が React コンポーネント同士の連携方法に掛かっているのが分かります。
制約2: グローバル変数そのままで使えない
React でコンポーネントの状態を更新する方法は、基本的に useState(initialValue)
の返り値から取り出した set〇〇
関数(セッター関数)のみです。
// ❌ 更新ができない
let wrongExternalVar = 0;
const Page: FC = () => {
return (
<div>
<div>{wrongExternalVar}</div>
<button
onClick={() => {
wrongExternalVar++;
}}
>
plus
</button>
</div>
);
};
なので、イベントハンドラの中で普通に wrongExternalVar++
とインクリメントしても、それが画面に反映されません。 「Svelte と違って不便」と言わないでください
「useState (または useReducer) で宣言したもの以外はステートとして扱われない」のがわかる uhyo さんの記事はこちらです。
ただし、useEffect
や useSyncExternalStore
を使用した「サブスクリプション」と呼ばれるパターンを使えば可能ではあります。
レベル1: 内容結合 ―― 高水準言語なので起こらない
内容結合とは、あるモジュールと他のモジュールが一部を共有するようなモジュールの結合の仕方です。
他モジュール内の外部宣言していないデータを直接参照したり、命令の一部 を共有したりする場合が、これに相当します。
内容結合は、高水準言語を使用したモジュールには見られませんが、アセンブラ言語などを使用したモジュール にはしばしば見られます。
上田勲(pp.271-272)
✅React において「内容結合」は起こりません。
JavaScript ではこういった低水準の操作を行えないので、内容結合は起こりません。(WASM だと無理やり実現できるかもしれない)
レベル2: 共通結合 ―― グローバル or Context. 賢く使おう
共通結合とは、共通域に定義したデータを、いくつかのモジュールが共同使用 するような結合形式です。
共通域の定義データとは、いわゆる「グローバル変数」のことです。
共通結合は、結合度が高く、デメリットが多くあります。
上田勲(p.272)
✅React において「共有結合」とは、Context、生の DOM、ブラウザ機能、またはフレームワーク・ライブラリが提供する各機能を用いて、コンポーネントが間接的に連携する結合形式です。
React の仕組みに翻訳すると上記のようになります。詳しく見てみましょう。
グローバル変数そのままで使えない
ファイルのトップレベルに書かれた再代入可能なグローバル変数やミュータブルなオブジェクトは、そのままで React コンポーネントから変更を検知できません。詳しくは先ほどの「制約2」で説明しています。
しかし、React には「グローバル的」な機能、「範囲を制限したグローバル」のような機能がいくつかあります。便宜的にこれらによって影響しあうのを共通結合と見なすことにします。
それぞれ見てみましょう。
グローバルっぽい読み書き
グローバルっぽくデータを読み書きできる機能には次のようなものがあります。
- クエリパラメータの読み書き
- 例: Next.js の
useSearchParams
フック,searhParams
Props
- 例: Next.js の
- クッキーの読み書き
- 例: Next.js の
cookies
機能
- 例: Next.js の
- その他
- 例: React (canary) の
useFormStatus
は Actions と連動する
- 例: React (canary) の
クエリパラメータやクッキーについては、「必ず関数等を通して読み書きする」ようにする方法があります。これで、読み書きに使う情報を(どんなキーを使って情報を読み書きするか)それらの関数等に閉じ込めて整合性を取りやすくなるでしょう。
import { cookies } from "next/headers";
export const getMyName = () => {
return cookies().get("my-name");
};
export const setMyName = (value: string) => {
cookies().set("my-name", value);
};
また、「これらの情報を扱う(ページに近い・具体的なセクションの)コンポーネント」と「UI に関心のあるコンポーネント」を分けることでも混乱を防げる可能性があります。
個々の結合が密であっても、場所が限定されていたり、その個数が少ないことでデメリットが軽減できていると言えるでしょう。
ちなみに、useFormStatus
は用途が狭く限定されているので、乱用しづらくなっています。
Suspense, Context は特定範囲内のグローバル
React に特有の機能として、Suspense や Context といったものがあります。これらも共通結合に含めてみました。
Suspense に関連する機能 (use()
や startTransition
など) は、 <Suspense> ~ </Suspense>
に囲われた単位で表示をコントロールできます。
Context を使うことで <Parent><Child /></Parent>
のような包含関係のとき、Parent と Child の間での暗黙的なやりとりが可能になります。なので Radix UI Primitives が提供しているような、汎用的で複数要素が連動するタイプのコンポーネント(例: Select)で多用されます。 Compound Component と呼ばれるパターンです。
Suspense, Context はグローバル的にも使える
Suspense を自分で書かずに Next.js App Router で普通にページ内で Susense に関連する機能を使用できます。ルーターによってページ単位の Suspense が自動的に挟み込まれているからです。
Context についても同様です。先のセクションでも述べた useSearchParams
のような フレームワークによって提供される機能 も Context を通じて提供されています。ほかにも、アプリ全体のあちこちから使用されるライブラリの機能を提供する ためにも使用されます。
(例: TanStack Query の QueryClientProvider
, Chakra の ChakraProvider
)
改善が必要/不要なケース
ここまで説明したように、「共通結合は密結合だから悪!」なのではありません。使うべき場所で適度に使えばとても役に立ちます。
これに対し、共通結合を使えば、データの受け渡しのためのパラメータの指定を回避できます。これはモジュールの作成を容易にし、共通結合の短所を超えるメリットをもたらす場合もあります。現実に、共通結合を採用している場面も多くあります。
上田勲(p.274)
しかし、Props のバケツリレーが煩わしいといって、安易に Context 等に頼るべきではありません。
しかし、そもそも、受け渡しパラメータの数が多いのは、モジュール化が適切でないことが原因の大半です。モジュールを再設計して、管理しているデータの位置を再考することにより、パラメータの数を少なくすることができる場合が ほとんどです。
上田勲(p.274)
特に、React では安易に Context を使わずに、まず次のような方法を取れないか検討すべきです。
- まず Props で渡すことを前提にする。
- コンポーネントの構造を見直す。
- 無駄なレイヤーの除去
- コンポジションパターン
- Push State Down パターン
特に、UI の細かな状態の管理はそれぞれライブラリを使うことで除去できます。
サーバーから取得したデータやクッキーの読み取り等は、ライブラリや Server Component 等の機能をうまく使って小さな具体的コンポーネントに集める ことができます。(こちらはあまりベストプラクティスやライブラリ機能が出揃っていませんが。)
「実質 Context を使っちゃってるじゃん!」と思われるかもしれませんが、ライブラリ・フレームワークの機能そのものなら、ドメイン知識は持たないので問題ありません。
レベル3: 外部結合 ―― React では原則として禁止
外部結合とは、外部宣言したデータを共有したモジュール間の結合形式です。
外部宣言した定義とは、例えば、public 宣言された変数のことです。
上田勲(p.274)
✅React において「外部結合」は基本的に不可能です。エスケープハッチとしてref
と useImperativeHandle
で実装できます。
React においては、前置きの部分で述べたようにデータの流れの方向が厳しく限定されています。「親から子の情報を読み取る」「子から親の情報を読み取る」「親から子のメソッドを呼ぶ」の 3 パターンはどれも実装不可能です。
Props を通じて「子から親にイベントを通知する」「親から子にデータ更新が伝播する」 の原則に可能な限り従うべきです。そうすれば自然と「レベル4:制御結合」以上の疎結合を達成できます。
「子から、親から Props で渡されたオブジェクトのメソッドを実行する」ことは可能ですが、親自体ではなく親に保存した別のオブジェクトを扱っているので「レベル4」以上と考えて差し支えないでしょう。
ただし、エスケープハッチ(緊急用の手段)も用意されています。 Ref を使ったメソッドの呼び出しです。 関数コンポーネントからメソッドを公開するのは useImperativeHandle
フックによって可能になります。
厳しく機能を限定することで安全性・疎結合を保っていながら、このように現実的な問題を解決するための緊急用手段も別で用意してくれているのが React の特徴です。
レベル4: 制御結合 ―― 論理的凝集におちいるので注意
制御結合とは、呼び出し側のモジュールが、呼び出されるモジュールの制御を指示するデータを、パラメータとして渡す結合形式です。
制御結合では、パラメータの 1 つとしてスイッチ変数を渡し、呼び出されるモジュールがその時に行う機能を指示します。このため、呼び出し側は、呼び出されるモジュールの論理を知っている必要があり、相手をブラックボックス扱いにできず、結合度が強くなります。
上田勲(p.275)
✅React における「制御結合」は、文字通り親コンポーネントが子コンポーネントの制御を指示するデータを Props として渡す結合形式です。
ライブラリ等であれば許容できる
汎用性が極めて高く、繰り返しを避けたい意識が強い場合、またスイッチ引数によって型が変わらない場合には許容されます。
例: ライブラリ「MUI」含まれる Button
コンポーネントの variant
Prop
基本的には避けたい
しかし、それ以外のケースには、バグを作り込む可能性があるため、汎用的なライブラリのコンポーネントでなければ避けるべきです。(つまりほとんどの場合は避けましょう。)論理的凝集 としてもよく知られたパターンです。
「表示される場所によって内容が微妙に異なるリスト」を実装するときには、以下のように制御結合なコードを書いてしまうことが多いです。
showAuthor
のフラグによって、videos
の一部のデータを使ったり使わなかったりするので、かなりの複雑さが各ページコンポーネントにまで波及してしまいます。しかもフラグが増えるほどに VideoList
の複雑さが増えそうです。
export const VideoList = ({
videos, // 動画のデータリスト
showAuthor = false, // true なら投稿者情報を表示 / false なら非表示
}) => {
// 中略
{videos.map(item => (
<VideoItem
key={item.id}
author={showAuthor && item.author}
title={item.title}
/* ... */
/>
))}
// TOP ページ
<VideoList
videos={/**/}
showAuthor // このページでは投稿者情報を表示する
>
// マイページ
<VideoList
videos={/* author は不要だから undefined を代入...みたいなことをやる */}
// このページでは投稿者情報は非表示
>
このコードを改善して疎結合にするには、DRY(Don't Repeat Yourself) のことは一旦わきに置いて、思い切って「たまたま似ているだけ」の共通化をやめてしまいましょう。(正直、これをだけで十分にクリーンなアーキテクチャになります)
このコードでは、疎結合だけでなく、ある程度の DRY と両立するために コンポジション(特にコンパウンド) パターンを使用しています。しかし、難しい場合ば DRY を諦めて、VideoList
のようなものを用意せずそれぞれのページで愚直に書いてしまっても良いです。誤った共通化よりは、ほどほどに共通化して深追いしない方がマシです。
VideoList
と VideoItem
は、CSS の観点だとベッタリ密結合だと思われるかもしれません。しかし、ここは「組み合わせて使うことが明らかになので妥協できる」箇所だと思います。「React では凝集度が重要で、結合度はさほど重要でない」ポイントです。知らんけど。
export const VideoList = ({ 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>
レベル5: スタンプ結合 ―― 無駄なデータにだけは注意
スタンプ結合とは、共通域にないデータ構造を、2 つのモジュールで受け渡しするような結合形態です。
データ構造の受け渡しは、パラメータを介して行います。
ただし、スタンプ結合の場合、受け渡すデータ構造の一部を使用しないことがあります。不必要なデータまで受け渡しする点が、結合度を少し強くしています。
上田勲(pp.275-276)
✅React における「スタンプ結合」は、不必要なプロパティを含む Props を子コンポーネントが受け取っている結合形式です。
export type PostInfo = {
title: string;
slug: string;
hoge: string;
};
import { FC } from "react";
import type { PostInfo } "./_types/post-info"
type Props = {
data: PostInfo;
};
export const PostItem: FC<Props> = ({ data }) => {
return (
<div>
<div>{data.title}</div>
<div>slug: {data.slug}</div>
</div>
);
};
この PostItem では、 data.hoge
プロパティの値が読み取られていません。受け取った Prop たちの一部を受け取るだけで使っていないのでスタンプ結合になります。
十分に疎結合なので、サイト・アプリの規模・性質によっては問題ないと思います。
普通はわざわざ表示に使われないプロパティを宣言することはありませんが、データの取得・保持に使う型をそのまま使った場合にありがちです。
使用場面が多くて大規模・ページごとのバリエーションが多ければ、少しブラッシュアップの余地があります。次のレベルを見てみましょう。
レベル6: データ結合 ―― 理想的
データ結合とは、モジュール間のインタフェースとして、スカラ型のデータ要素だけを、パラメータとして受け渡す結合形式です。
相手モジュールをブラックボックス化できるので、結合度は一番弱くなります。
モジュール間の結合は、明確化されたパラメータでデータを受け渡す、データ結合が一番よいとされています。
上田勲(p.276)
✅React における「データ結合」は、不必要なプロパティがないように Props を子コンポーネントが受け取っている結合形式です。
前の節で提示したコードには不要な hoge があったのでそれを無くしました。ついでに Props をフラットにして、さらに 「取得データ」の型宣言から切り離して別々の型にしてみました。
疎結合のお陰で実現できる利用の仕方の柔軟さを示すために、1つの要素を追加しました。
-
著者情報
- 投稿一覧ページでは表示
- ユーザープロフィールページでは非表示
import { FC } from "react";
type Props = {
title: string;
slug: string;
author?: {
name: string;
id: string;
};
};
export const PostItem: 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) => (
<PostItem
key={item.slug}
title={item.title}
slug={item.slug}
author={{
name: item.author.name,
id: item.author.id,
}}
/>
))}
// マイページは、著者情報の表示なし
{items.map((item) => (
<PostItem
key={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";
// 投稿一覧ページは、著者情報の表示あり
const PostsPage: FC = async () => {
const items = await fetchNewPosts();
return (
<div>
{items.map((item) => (
<PostItem
key={item.slug}
title={item.title}
slug={item.slug}
author={{
name: item.author.name,
id: item.author.id,
}}
/>
))}
</div>
);
};
export default PostsPage;
import { FC } from "react";
import { fetchMyPosts } from "./fetch-my-posts";
import { PostItem } from "../posts/post-item";
// マイページは、著者情報の表示なし
const MyPage: FC = async () => {
const items = await fetchMyPosts();
return (
<div>
{items.map((item) => (
<PostItem
key={item.slug}
title={item.title}
slug={item.slug}
// author は非表示なので指定しない
/>
))}
</div>
);
};
export default MyPage;
このように、
- 単なるボタン、インプットではなく、特定のエンティティ(?)を表示する
- さまざまなページに表示される
- それぞれで表示内容が異なっている
ようなコンポーネントでありながら、使用側(各ページ本体)のコードを一目見れば大体の表示内容の推測が分かりやすく実装できます。
「データの取得側の型定義」と「コンポーネント自体の表現のしかたのバリエーションを示す Props の型定義」を別々にして、スタンプ結合からデータ結合に改善することで、このような恩恵を得られます。
番外編:「あけすけな子供」原則
React においては、「子が親のことを知りすぎる」のは密結合になるので避けたほうが良いですが「親が子のことを少し知りすぎる」ことは許容されます。
これは、親コンポーネントの中に子がダイレクトに登場し、子は親との通信を子自身の Props を通じて(親の情報を知らずに)行うからです。
兄弟コンポーネントが連携するには、「子の状態」に見えるものを「親の管理するステート」とみなして、Props として子に渡す必要があることは、制約1: コンポーネント間連携方法の制限を見れば明らかです。
(Context を使わずに)状態を共有したければ、Props を使って親子をデータ結合させる必要があります。 もっと結合度が低い「結合なし」としたくても、これを省略するのは不可能です。
番外編:「親の心子知らず」原則
一方で、子コンポーネントは、親コンポーネントを「Props を通じてやりとりできる、モジュールの外の世界」「ブラックボックス」のように扱うべきです。
先程のコード例で PostItem
の Props の型を、他の型からの引用ではなく自己完結した宣言にしたことには、「親がどんなデータを取得したか知らずに済む」という利点もあります。
疎結合になるだけではなく、コンポーネントが「変わりやすさ」によってレベル別に分類することは、コード全体の保守性を向上させる効果があります。
番外編: イベントハンドラと情報隠蔽
イベントハンドラの Props 名を考えてみましょう。onClickPlayMovie
のような操作方法の詳細を含めた命名よりも、 onPlayMovie
のように「ユーザーがやりたいこと」にフォーカスした命名のほうが有利です。
<Toolbar
onPlayMovie={() => alert('Playing!')}
onUploadImage={() => alert('Uploading!')}
/>
アプリには特定の「やりたいこと」(例: 再生を開始する)に対して複数の操作方法がありえます。ボタンのクリック、ショートカットキー、タッチパネルのスワイプ等のジェスチャーなどです。
そういった「複数の方法」の存在を Toolbar コンポーネントが内側に隠蔽してくれるため、使用する側のコンポーネントとの連携をシンプルにできます。
▼ 以上はこの記事を参考にして、コード一部を引用しています。
こちらも結合度のレベルとは(おそらく)無関係な要素ですが、「情報隠蔽」によって「結合の質」のようなものが改善します。
モジュールが、クライアントが知る必要のない内部の詳細部分を隠蔽すれば、インタフェースが小さくなり、やりとりがシンプルになり、コード全体の複雑性を下げることができます。
クライアントから見ても、余計な情報が見えないため、モジュールの使い方がシンプルになり、使い勝手がよくなります。
また、公開されている部分が少なければ、モジュールの内部に変更を留め置くことができる可能性が高くなります。これにより、コードの変更の 波及を最小限に抑えることができます。
上田勲(pp.121-122)
「中身を固定していない汎用的なダイアログ」のような、汎用性のある(ドメイン知識を持たない)コンポーネントを書く場合には、この「情報隠蔽」原則と「あけすけな子供」原則の間でのトレードオフになります。
番外編: CSS
どれだけ Props を頑張っても、CSS は CSS で大変です。
Flexbox, CSS Grid とか、コンテナクエリーとか、 min-width: 0
とか、「親の状態によって子の表示が崩れる」のを防ぐためには泥臭く頑張る必要があります。
余白が足りないうまく言語化できないのでこの記事では省略します。
まとめ
- React コンポーネント自体が互いに疎結合になるような仕組みである
- 結合度以前に「Lift State Up」のように「正しく連携するための最低限のルール」が優先
- Context は制限付きの密結合。用法用量を守って使おう
- Props の型や名称の工夫によって、結合の質を改善できる
- CSS が絡むと用意に密結合が出るので難しい
Discussion
とても参考になりました!
重箱の角をつつくようで申し訳ないのですが、以下の記載はNumberDisplay, Buttonではないでしょうか?
ありがとうございます!修正しました!