ADR 020: Feature 間コンポーネント合成のパターン
- Status: Accepted
- Date: 2026-04-21
- Deciders: Lead Engineer
Context
Section titled “Context”ADR 001 で採用した Feature-Sliced Design (FSD) では features/xxx/ から
features/yyy/ を直接 import することを禁止している。しかし PoC 実装の過程で
以下の 2 箇所に feature 間の直接 import が生じていた。
features/objectives/components/objective-card.tsx→features/key-results/components/key-result-list.tsxfeatures/key-results/components/key-result-item.tsx→features/tasks/components/task-list.tsx
いずれも親エンティティの UI が子エンティティの一覧を内部に表示する必要があり、 「ドメイン的に正しいがモジュール境界を侵害する」構造であった。
Decision
Section titled “Decision”ページ層(app/)から render prop / children で依存を注入するパターンを
標準とする。
- 子を表示する feature コンポーネントは
childrenまたはrender*prop で スロットを公開する app/のページコンポーネント(全 feature を知ってよい最上位層)が render prop 経由で子 feature のコンポーネントを注入する- feature コンポーネント同士は互いの存在を知らない
// app/dashboard/page.tsx(最上位層 — 全 feature を知ってよい)import { ObjectiveList } from "@/features/objectives/components/objective-list";import { KeyResultList } from "@/features/key-results/components/key-result-list";import { TaskList } from "@/features/tasks/components/task-list";
export default function DashboardPage() { return ( <ObjectiveList renderKeyResults={(objectiveId) => ( <KeyResultList objectiveId={objectiveId} renderTasks={(keyResultId) => <TaskList keyResultId={keyResultId} />} /> )} /> );}// ❌ import { KeyResultList } from "@/features/key-results/..."; ← 禁止export function ObjectiveCard({ objective, onDelete, children }: { objective: Objective; onDelete: (...) => void; children?: React.ReactNode; // ← KR リストはここに注入される}) { return ( <Card> <CardHeader>...</CardHeader> <CardContent>{children}</CardContent> </Card> );}render prop 内でのコンポーネント定義禁止
Section titled “render prop 内でのコンポーネント定義禁止”render prop のコールバック内で新しいコンポーネントを定義して返すことは 禁止する。毎レンダーで新しい関数参照が生まれ、React の reconciliation が 「別のコンポーネント型」と判断してアンマウント → リマウントを繰り返すため、 state のリセットや不要な API 再フェッチが発生する。
// ❌ NG: 毎レンダーで新しいコンポーネント型が生まれる → state が毎回リセットrenderKeyResults={(id) => { const WrappedList = () => <KeyResultList objectiveId={id} />; return <WrappedList />;}}
// ✅ OK: 既存コンポーネントの JSX をそのまま返す → 型が安定し差分更新されるrenderKeyResults={(id) => <KeyResultList objectiveId={id} />}React は仮想 DOM の差分比較時にコンポーネントの関数参照を型の同一性判定に
使う。render 内で定義した関数は毎回新しいオブジェクトになるため
旧関数 !== 新関数 → 完全な再マウントとなる。一方、モジュールスコープで定義
された KeyResultList は常に同じ参照なので、props の差分のみが評価される。
Alternatives Considered
Section titled “Alternatives Considered”-
children のみ(render prop なし)で合成:
ObjectiveCardにchildrenを渡すだけなら簡潔だが、ObjectiveList内でKeyResultListを import する必要があり、feature 間の直接 import がObjectiveCard→ObjectiveListに移動するだけで根本解決にならない。却下 -
shared/ への移動:
KeyResultListをshared/components/に昇格する案。しかしKeyResultListはuseKeyResults、useUndoableDeleteKeyResult、CreateKeyResultForm等の KR ドメイン専用ロジックに依存しており、それらを全て shared に移動すると FSD のカプセル化が崩壊する。却下
Consequences
Section titled “Consequences”Positive
Section titled “Positive”features/間の直接 import が完全に排除され、ADR 001 の原則が守られる- 各 feature が独立してテスト・削除可能になる
- 合成の責務が
app/に集約されるため、依存関係の全体像がページ単位で見える
Negative
Section titled “Negative”- ページコンポーネントの記述量が増える(render prop のネストが深くなり得る)
- コンポーネントの props に
render*/childrenが増え、型定義が冗長になる - ネストが 3 階層以上になった場合、可読性が低下する可能性がある (その場合は Context や Compound Component パターンへの移行を検討する)