Container/Presentationalパターン再入門
はじめに
フロントエンドの有名なデザインパターンの1つにContainer/Presentationalパターンというものがあるのですが、いくつか記事を見ていると記事によって多少実装方法が異なってたり、hooksによるContainer/Presentationalパターンの置き換えなどもよく見かけるのでContainer/Presentationalパターンについて改めて整理してみました。
Container/Presentationalパターンとは
Container/Presentationalパターンとは、ロジックとUIを分けて実装することで関心の分離を図るフロントエンドのデザインパターンです。
ロジックを責務とするContainer Componentと、UIを責務とするPresentational Componentに分けて実装することで関心の分離を実現します。
Container Component
Container Componentはアプリケーションのロジックに関心を持ち、APIや状態管理ライブラリから取得したデータをそれぞれのPresentational Componentに渡す役割を持ちます。
Container Componentで使用するPresentational Component以外で独自で要素をレンダリングすることはなく、そのためスタイルを持つこともないです。
またAPIで取得したデータやロジックを扱うためContainer Component内部で状態を持つことがあります。
Presentational Component
Presentational ComponentはUIに関心を持ち、Propsで受け取ったデータをどのように表示するかのみを役割として持ちます。
そのためProps以外の方法でデータを受け取ることはなく、原則としてPresentational Component内部で状態を持つこともありません。(UIに関する状態であれば持つこともある)
イメージ図
Container ComponentとPresentational Componentの比較
Container ComponentとPresentational Componentの違いを簡単にまとめると下記のようになります。
Container Component | Presentational Component | |
---|---|---|
責務 | ロジック | UI |
状態 | 持つ | 原則持たない |
データの受け取り元 | 状態管理ライブラリ、API等 | Props |
特に重要なのがPresentational Componentは原則状態を持たず、データの受け取り元がPropsに限定されている点で、このルールが守られることでPresentational Componentの責務が小さくなり、この後紹介するメリットに繋がります。
メリット
Container/Presentationalパターンのメリットとして大きく分けて下記の3つが挙げられます。
Componentの責務が明確になる
ロジックはContainer Component、UIはPresentational Componentといった形で責務がはっきりしているので、どこで何を実装しているのかがわかりやすくなります。
そのためUIの微調整をしたいという場合にはPresentational Componentのみを改修すれば良いですし、UIはそのままでロジックだけ変更したいという場合にはContainer Componentのみを改修すれば良くなります。
改修内容に応じてどのコンポーネントを修正すればいいかの判断がつきやすくなり、また影響範囲も限られるので保守性が高くなります。
テストがしやすくなる
ロジックのテストであればContainer Component、UIのテストであればPresentational Componentといった具合に、それぞれのComponentに対して何をテストすれば良いかが明確になるのでテストがしやすくなります。
Presentational Componentの再利用性が向上する
Presentational ComponentはPropsのみに依存しているので、定義されているPropsさえ渡してあげればどのComponentからも利用することができます。そのPropsのがどのようなデータを元に、どのような過程を経て渡されているかをPresentational Componentは意識する必要がありません。
仮にPresentational Componentが特定の状態管理ライブラリやAPIに依存している場合は使用場面が限られてしまうので、データの受け取り元がPropsのみに限られているという部分が肝になります。
2通りの実装方法
具体的な実装方法に関してです。
Container/Presentationalパターンで検索すると大きく分けて2通りの実装方法がヒットします。自分が調べた限りでは2つの実装方法を区別して説明されている記事はなく、それぞれの記事でどちらか一方のみが紹介されており、自分は実装する際にどの記事を参考にすれば良いのか悩みました。
これら2つの実装方法は明確に区別される必要があると思うので、便宜上「分類パターン」と「分割パターン」と命名し、それぞれの実装方法と使い分け方について説明していきます!
分類パターン
分類パターンは1つのコンポーネントをContainerかPresentationalのどちらかに分類する実装方法です。一般的にContainer/Presentationalパターンと言えばこちらの実装方法で、自分の認識ではContainer/Presentationalパターンの提唱者であるDan Abramov氏が元記事で紹介しているのもこちらの実装方法に分類されます。
例としてTodoのリストを取得して一覧表示するコンポーネントを考えてみます。
Container Componentに分類されるTodoContainer.tsx
とPresentational Componentに分類されるTodoList.tsx
をそれぞれ定義します。
TodoContainer.tsx
でTodoを取得するAPIリクエストを行い、レスポンスを状態としてコンポーネント内部に保持します。そしてそのTodoの配列をPresentational ComponentであるTodoList.tsx
に渡します。
import { useEffect, useState } from 'react';
import { TodoList } from "src/components/TodoList";
export const TodoContainer: React.VFC = () => {
const [todos, setTodos] = useState([]);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/todos')
.then((res) => res.json())
.then((todos) => setTodos(todos));
}, []);
return <TodoList todos={todos} />;
};
TodoList.tsx
はPropsで受け取ったTodoの配列をレンダリングします。
type TodoListProps = {
todos: {
id: number;
title: string;
completed: boolean;
}[];
};
export const TodoList: React.VFC<TodoListProps> = ({ todos }) => {
return (
<ul>
{todos.map(({ id, title, completed }) => (
<li key={id}>
<p>{title}</p>
<p>{completed ? '完了' : '未完了'}</p>
</li>
))}
</ul>
);
};
TodoList.tsx
は受け取ったPropsをレンダリングしているだけのPresentational Componentなので、再利用が可能です。例えばユーザーが所有するTodoのみを表示したいとなった場合にUserTodoContainer.tsx
のようなコンポーネントを用意して、その中でユーザーに紐づくTodoを取得してTodoList.tsx
に渡すようにすることでコンポーネントを再利用でき、UIは変更せず表示するデータだけを変えることが可能になります。
import { useEffect, useState } from 'react';
import { TodoList } from "src/components/TodoList";
export const UserTodoContainer: React.VFC = () => {
const [todos, setTodos] = useState([]);
useEffect(() => {
// userIdを指定して、ユーザーに紐づくTodoを取得
fetch('https://jsonplaceholder.typicode.com/todos?userId=1')
.then((res) => res.json())
.then((todos) => setTodos(todos));
}, []);
return <TodoList todos={todos} />;
};
分割パターン
分割パターンは1つのコンポーネントをContainerとPresentationalに分割する実装方法です。Container/Presentationalの2組1セットで1つのコンポーネントとして振る舞うので、基本的にContainer Component : Presentational Component = 1 : 1となります。
コンポーネントの構成としてはコンポーネントのディレクトリの中でcontainer.tsx
とpresenter.tsx
に分割して実装します。実装内容に関しては分類パターンとほぼ同じなので割愛します。
TodoForm
├── container.tsx
└── presenter.tsx
ここで注意が必要なのが分割パターンは分類パターンとは違いPresentational Componentの再利用が難しいという点です。分割パターンはあくまで2組1セットで1つのコンポーネントとして振る舞うのでpresenter.tsx
だけ別のContainer Componentから再利用するということができません。厳密にはやろうと思えばできるのですがそれをしてしまうと返って依存関係が複雑になり、保守性の低下につながるのでデメリットの方が大きくなってしまうというのが個人的な意見です。
ではなぜ分類パターンを使用するかというとpresenter.tsx
から状態を完全に抽出できるからです。状態をcontainer.tsx
に寄せることでpresenter.tsx
の振る舞いをPropsで完全に制御できるのでstorybookなどを利用した画像回帰テストと相性がよく、画像回帰テストのカバレッジを上げることができます。
どちらを使うべきか
前述したように分割パターンの場合だとPresentational Componentの再利用が難しいので、Container/Presentationalパターンのメリットを一通り教授できる分類パターンを採用するのが良いと思います。
ただ状況に応じて画像回帰テストのカバレッジをあげたいなど、コンポーネントの状態を完全に抽出したい場面では分割パターンの採用もありだと思うので、目的に応じて使い分けるのがいいと思いました。
hooksによるContainer/Presentationalパターンの置き換えについて
元記事でも今はhooksが使え、恣意的な分割をせずともロジックとUIを分けることができるのでこのパターンにこだわりすぎないようにと述べられています。もちろん要件や場面にはよりますが、自分の考えとしてはhooksが使える今でもContainer/Presentationalパターンは採用した方がいい思っています。
理由としてはhooksにロジックを抽出したとしてもhooksを呼び出しているコンポーネントがhooksに依存してしまい、先ほど紹介した分割パターンのようにコンポーネントとhooksが2組1セットのようになりコンポーネントの再利用性が低下するからです。
先ほどのTodoListを例に考えて見ると、APIリクエストの部分をhooksに切り出すことでコンポーネントを分けなくとも下記のように実装することができます。
import { useEffect, useState } from 'react';
export const useTodos = () => {
const [todos, setTodos] = useState([]);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/todos')
.then((res) => res.json())
.then((todos) => setTodos(todos));
}, []);
return todos;
};
import { useTodos } from 'src/hooks/useTodos';
export const TodoList: React.VFC = () => {
const todos = useTodos();
return (
<ul>
{todos.map(({ id, title, completed }) => (
<li key={id}>
<p>{title}</p>
<p>{completed ? '完了' : '未完了'}</p>
</li>
))}
</ul>
);
};
APIリクエスト部分をhooksに切り出すことでロジックとUIの分離には成功しました。ただこれだと先ほどの例のようにユーザーに紐づくTodoだけを取得したいとなった時にこのままだと再利用ができません。
同じコンポーネントを使用して実現しようとするとhooks、コンポーネント両方に修正が必要になってしまい、さらに他の箇所で再利用したいとなった時にも修正が必要になる可能性が高くなります。
逆に下記のようなコンポーネントを新たに実装した場合を考えてみます。
import { useEffect, useState } from 'react';
export const useUserTodos = () => {
const [todos, setTodos] = useState([]);
useEffect(() => {
// userIdを指定して、ユーザーに紐づくTodoを取得
fetch('https://jsonplaceholder.typicode.com/todos?userId=1')
.then((res) => res.json())
.then((todos) => setTodos(todos));
}, []);
return todos;
};
import { useTodos } from 'src/hooks/useUserTodos';
export const UserTodoList: React.VFC = () => {
const todos = useUserTodos();
return (
<ul>
{todos.map(({ id, title, completed }) => (
<li key={id}>
<p>{title}</p>
<p>{completed ? '完了' : '未完了'}</p>
</li>
))}
</ul>
);
};
新たにコンポーネントを実装することで既存のコンポーネントには修正を加えずに済みますがレンダリング部分がDRYではなくなり、UIに修正が必要になった際に2つのコンポーネントの両方に修正が必要になってしまいます。なので最初の例の様にTodoListはPropsで受け取ったtodoをレンダリングすることだけを責務にとどめた方が、UIに関する記述がDRYになり再利用もしやすくなります。
ではロジックは今まで通りContainer Component内で実装した方ががいいのかというとそうではなく、テストのしやすさやロジックの再利用という観点からもhooksに切り出すのがいいと思います。
そしてそのhooksを呼び出し、Presentational Componentに適切なデータを渡す橋渡し的な役割がContainer Componentになります。
先ほどTodoListの例で実装すると最終的なContainer Componentは下記になります。
import { useTodos } from 'src/hooks/useTodos';
import { TodoList } from "src/components/TodoList";
export const TodoContainer: React.VFC = () => {
const todos = useTodos();
return <TodoList todos={todos} />;
};
これによりhooksとPresentational Componentが疎結合になり、Container Component内で使用するhooksを変えることでロジックの変更も容易になります!
hooksが使えるようになったことでContainer Componentがロジックを切り出すための役割から、ロジックとUIをつなぎ合わせるための役割にシフトしたイメージです。
まとめ
分類パターンと分割パターンとでコンポーネントの再利用性が大きく変わるので注意が必要だと思いました。ただこの分類自体自分が勝手に名前をつけて分けたもので、自分の理解が足りず認識が間違っている可能性もあるのでその際はコメントを頂けると嬉しいです。
また今はhooksでロジックの切り出しは行えますが、hooksを直接使用するのではなく一度Container Componentを噛ませることでそれぞれが疎結合になり保守性の向上につながるので、今でもContainer/Presentationalパターンは有用だと思います!
今回のテーマでLT登壇もしているので興味のある方は見てみてください!
Discussion