子コンポーネントの責務が曖昧で密結合していないか?
ReactでUIを構築する際、「コンポーネントを分けているのに、結局全部親依存」みたいなコードに出会ったことはないだろうか。
このような構造は一見整理されているようで、実は設計ミスをはらんでいる。
本稿では、親子コンポーネント間の密結合と責務の曖昧化について、レビュー時に見るべきポイントを整理する。
密結合とは何を指しているのか?
「密結合」とは、主に以下のような状況を指す。
- 親の内部状態を子が直接参照・依存している
- 子が表示以外のロジック責務も併せ持っている
- propsの構造が変更されたときに子も影響を受ける
- props名がドメイン知識に引きずられて冗長かつ曖昧
密結合とは
密結合(tight coupling)とは、コンポーネント間の依存度が高く、片方の変更がもう一方に即座に影響を及ぼす設計を指す。テストや再利用が難しく、保守性が低下する。
よくある構造:親のロジックが子に染み出しているケース
親のロジックが子に混入している構造
export const Parent = () => {
const [count, setCount] = useState(0);
const handleClick = () => setCount((c) => c + 1);
return <CounterButton onClick={handleClick} count={count} />;
};
export const CounterButton = ({ onClick, count }: { onClick: () => void; count: number }) => {
return <button onClick={onClick}>クリック数: {count}</button>;
};
@Reviewer一見分離されているように見えるが、実際には子が完全に親の都合でしか動作しない構造。汎用性が低く、再利用先でも必ずcountを渡さなければならない。
このコードでは表示・ハンドリングを分けているように見えるが、CounterButton
は「数値とイベントのペア」という構造に固定されており、柔軟性がない。
より柔軟な責務分離の例
以下は、表示責務を独立させるための分離構造。
責務を分離した構造
export const Parent = () => {
const [count, setCount] = useState(0);
const handleClick = () => setCount((c) => c + 1);
return (
<div>
<Label text={`クリック数: ${count}`} />
<IncrementButton onClick={handleClick} />
</div>
);
};
export const Label = ({ text }: { text: string }) => <span>{text}</span>;
export const IncrementButton = ({ onClick }: { onClick: () => void }) => (
<button onClick={onClick}>+</button>
);
@Reviewer表示とイベントが分離されており、それぞれ独立して利用可能。テストやスタイル調整にも対応しやすい構造。
なぜ密結合が問題になるのか?
以下のような影響が生じやすい。
- テスト困難性:親がないと子がテストできない
- 再利用性の欠如:汎用コンポーネントとして抽出できない
- 命名の一貫性崩壊:親のドメイン知識が子に伝播する
- 責務混在:副作用やロジックが子コンポーネントに含まれてしまう
実例で見る密結合と疎結合の違い
密結合を見抜くレビュー観点リスト
状態管理と密結合の関係
状態管理ライブラリ(例:Recoil, Zustand, Jotaiなど)を導入すれば、親子のprops伝搬を最小限に抑えられる。
ただし、それは「密結合を外から見えにくくする」だけのケースもあるので、構造そのものが適切であるかをレビューで確認することが重要。
どうしてもpropsが必要な場合の対処策
useMemo
やuseCallback
で意図を明確に保つchildren
パターンで自由度を確保- props構造をflattenしすぎない(ネスト構造も視野に)
まとめ
密結合な構造は一見わかりやすく見える反面、実務では再利用性・テスト性・保守性すべてで足を引っ張る。
レビュー時には、props構造や表示責務の混在に注意し、「この子コンポーネントは本当に独立して機能しているか?」を常に問い直す視点が必要だ。