はじめに
ドラッグ&ドロップ機能を持つリストコンポーネントをリファクタリングする機会がありました。
既存の実装は render props パターンを採用していましたが、コードを読むたびに「このコンポーネント、何をレンダリングしているんだっけ?」と迷うことが増えていました。
childrenパターンへの移行を検討する中で、「どちらを使うべきか」の判断基準を言語化できたので共有します。
Before:render propsの課題
- ListContainer + renderOverlay
type ListContainerProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
renderOverlay: (item: T) => React.ReactNode;
};
function ListContainer<T extends { id: string }>({
items,
renderItem,
renderOverlay,
}: ListContainerProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null);
const activeItem = items.find((item) => item.id === activeId);
return (
<DndContext onDragStart={(e) => setActiveId(e.active.id)}>
<SortableContext items={items}>
{items.map((item) => renderItem(item))}
</SortableContext>
<DragOverlay>
{activeItem && renderOverlay(activeItem)}
</DragOverlay>
</DndContext>
);
}
使用例
<ListContainer
items={tasks}
renderItem={(task) => (
<SortableItem key={task.id} id={task.id}>
<TaskCard task={task} />
</SortableItem>
)}
renderOverlay={(task) => <TaskCard task={task} isDragging />}
/>
課題
1. コンポーネントの責務が不明確
ListContainerを見ただけでは、何がレンダリングされるかわからない。renderItemとrenderOverlayの中身を追わないと全体像が見えない
2. 状態管理がコンテナに集中
activeIdの管理がコンテナ内に閉じ込められており、オーバーレイの表示ロジックを変更したい場合にコンテナ本体を修正する必要がある
3. propsが増えやすい
機能追加のたびにrender関数が増え、propsの数が膨らんでいく
After:childrenパターンへの移行
- (ListContainer + ListOverlay分離)
type ListContainerProps = {
children: React.ReactNode;
};
function ListContainer({ children }: ListContainerProps) {
return (
<DndContext>
<ListContextProvider>
{children}
</ListContextProvider>
</DndContext>
);
}
type ListOverlayProps<T> = {
items: T[];
children: (item: T) => React.ReactNode;
};
function ListOverlay<T extends { id: string }>({
items,
children,
}: ListOverlayProps<T>) {
const { activeId } = useListContext();
const activeItem = items.find((item) => item.id === activeId);
return (
<DragOverlay>
{activeItem && children(activeItem)}
</DragOverlay>
);
}
使用例
<ListContainer>
<SortableContext items={tasks}>
{tasks.map((task) => (
<SortableItem key={task.id} id={task.id}>
<TaskCard task={task} />
</SortableItem>
))}
</SortableContext>
<ListOverlay items={tasks}>
{(task) => <TaskCard task={task} isDragging />}
</ListOverlay>
</ListContainer>
改善点
1. 構造が一目でわかる
使用側のJSXを見るだけで「SortableContextの中にアイテムがあり、ListOverlayがある」という構造が把握できる。
2. 責務が明確に分離
ListContainerはDnDのコンテキスト提供に専念し、ListOverlayはオーバーレイの表示に専念できる。
3. 柔軟な拡張が可能
新しい機能を追加する際も、新しい子コンポーネントを作るだけで済む。
判断基準の言語化
render propsを使うべきケース
- 親が子のレンダリングを完全に制御する必要がある場合
- レンダリングのタイミングや条件が親に依存する場合
- 子コンポーネントが単一で、構造が単純な場合
例えば、Headless UIのように「ロジックだけを提供し、見た目は完全に利用側に委ねる」というケースでは、render propsが適している。
childrenパターンを使うべきケース
- 複数の独立したコンポーネントを組み合わせる場合
- 使用側でレンダリング構造を宣言的に表現したい場合
- 各コンポーネントの責務を明確に分離したい場合
Compound Componentsパターン(のような構造)との相性が良い。
今回の判断ポイント
今回のケースでは、以下の点からchildrenパターンを選択しました。
1. リストとオーバーレイは独立した関心事
リストのレンダリングとドラッグ中のオーバーレイ表示は、それぞれ独立して考えられる機能だから。
2. 構造の可読性を優先
チームでコードを共有する上で、JSXを見ただけで構造が把握できることは大きなメリットだから。
3. 将来の拡張性
空の状態の表示やローディング状態など、機能追加の際にコンテナ本体を修正せずに済む。
まとめ
render propsとchildrenパターンは、どちらが優れているというものではないです。重要なのは「このコンポーネントは何を責務とするか」を明確にし、その責務に適したパターンを選ぶことであると考えました。
判断に迷ったときは、「使用側のコードを読んだ人が、構造をすぐに理解できるか」を基準にすると良いということを学びました。