コンテナ・プレゼンテーションパターンの拡張を試みた
はじめに
再利用性の高いコンポーネントを実現するための方法の一つに、コンテナ・プレゼンテーションパターンがあります。
アプリケーションロジックに関心を持つコンテナコンポーネントと、UIに関心を持つプレゼンテーションコンポーネントに分離することで、テスタブル且つ再利用性の高いコンポーネントを実現できるという考え方です。
今となっては、ReactやVueを扱う多くの現場で適用されているパターンかと思われますが、今回はこのパターンに対して、さらなる再利用性向上を目的とした拡張を試みてみたので、備忘録として残します。
前提
以下のような要件を持ったTODOアプリを作成します。
- ステータスごとにタスクが分類して表示したい
- 検索バーに入力された文字列を基にタスクがフィルタリングされてほしい
- リスト UI とカード UI で見た目を切り替えられる
また、次のコンポーネントを例に話を進めていきます。
const TodoListPresenter: FC<Props> = () => {
const [tasks, setTasks] = useState<Task[]>([])
useEffect(() => {
fetch('https://example.com')
.then((res) => res.json())
.then((tasks) => setTasks(tasks));
}, [])
return (
<ul>
{tasks.map(task => (
<li key={task.id}>{task.title}</li>
)}
</ul>
)
}
基のパターンを適用した場合
実際にコンテナとプレゼンテーションでコンポーネントを分離してみます。
const Presenter = ({ tasks }) => {
return (
<ul>
{tasks.map(task => (
<li key={task.id}>{task.title}</li>
)}
</ul>
)
}
const Container = () => {
const [tasks, setTasks] = useState<Task[]>([])
useEffect(() => {
fetch('https://example.com')
.then((res) => res.json())
.then((tasks) => setTasks(tasks));
}, [])
return <Presenter tasks={tasks} />
}
コンテナでデータの取得を行い、取得したデータをプレゼンテーションにPropsで渡すような形となります。
仮に同じUIで、タスクステータスがTodoのものだけ表示する
といった要件が発生した場合に、前提のコンポーネントでは新たにコンポーネントを定義する必要がありましたが、パターン適用後は以下のようなコンテナコンポーネントを新たに用意するだけで要件を満たすことができるようになりました。
const TodoContainer = () => {
const [tasks, setTasks] = useState<Task[]>([])
useEffect(() => {
fetch('https://example.com')
.then((res) => res.json())
.then((tasks) => {
const todoTasks = tasks.filter(task => task.status === 'TODO');
setTasks(todoTasks)
});
}, [])
return <Presenter tasks={tasks} />
}
ただ、③のようなUIに関する要件が新たに追加された場合を考えると、本パターンの適用だけでは要件を満すことが難しい。その理由は、コンテナがプレゼンテーションを静的に扱っているからだと考えます。
また、UIの種類が増えた場合に同じだけのロジックをUI側に提供するには、UIの種類だけコンテナを複製する必要がある。これはDRY原則に反している上に、単純にコード量も増えてしまうためよろしくない。
ということで、今回はこのような要件①②③を同時に満たせるような形に拡張してみる。
パターンの拡張を考える
基のパターンでの問題はコンテナがプレゼンテーションを静的に扱っている
ことであるならば、コンテナがプレゼンテーションを動的に扱えるようにすれば解決するのではないかと考えました。
実際にコードで表現してみます。
export interface TodoListPresenterProps {
tasks: Task[];
}
export interface TodoListContainerProps<T> extends TodoListPresenterProps {
Component: React.FC<T>
}
import { TodoListPresenterProps } from '../models'
const Presenter: FC<TodoListPresenterProps> = ({ tasks }) => {
return (
<ul>
{tasks.map(task => (
<li key={task.id}>{task.title}</li>
)}
</ul>
)
}
import { TodoListContainerProps } from '../models'
const Container: FC<TodoListContainerProps<T = {}>> = ({ Component, ...props }) => {
const [tasks, setTasks] = useState<Task[]>([])
useEffect(() => {
fetch('https://example.com')
.then((res) => res.json())
.then((tasks) => setTasks(tasks));
}, [])
return <Component {...props} />
}
上記のように、コンテナコンポーネントがプレゼンテーションコンポーネントそのものをPropsで受け取れるようにすることで、コンテナがプレゼンテーションに縛られることがなくなりました。
例として、新たにカードUIが追加された場合の構成は以下の通りです。
import { TodoListPresenterProps } from '../models'
interface CardPresenterProps extends TodoListPresenterProps {
onClick: (task: Task) => void;
{ /* プレゼンテーション側で受け取りたいPropsを定義 */ }
}
const CardPresenter: FC<CardPresenterProps> = ({ tasks, onClick }) => {
return (
<ul>
{tasks.map(task => (
<li key={task.id} onClick={() => onClick(task)}>
<img src={task.thumbnail} />
<p>{task.title}</p>
</li>
)}
</ul>
)
}
import { TodoListContainerProps } from '../models'
type Props<T> = TodoListContainerProps<T> & T & { /* コンテナでのみ扱うPropsの定義 */ }
const Container: FC<Props = {}> = ({ Component, ...props }) => {
const [tasks, setTasks] = useState<Task[]>([])
useEffect(() => {
fetch('https://example.com')
.then((res) => res.json())
.then((tasks) => setTasks(tasks));
}, [])
return <Component {...props} />
}
const App = () => {
return (
<Container<CardPresenterProps> Component={CardPresenter} />
)
}
パターンを拡張することで、UIだけでなくロジックの再利用性も高めることができました。
デメリット
拡張することで以下のようなデメリットはあるかなと思います。
- コード量微増
- 可読性低下
ただこれらに関しては、そもそもコンテナ・プレゼンテーションパターンを採用すると発生するものでもあるので、そこまで意識しなくても良いかなとも思います。
さいごに
拡張という言葉が正確か否かは自分でもわからないですw
この手の機能要件は実際結構あると思ったりしたので、同じようなこと考えている人いないか探してみたのですが見つからなかった(もしあったらごめんなさい🙇♂️)ので、何かしら役に立てれば嬉しいです。
加えて、アンチパターンであったり拡張パターンの問題点などを洗い出しきれていないこともあるので、もしあれば教えていただけると幸いです!
追記
勢いで書いたから気づかなかったのですが、改めて考えるとHOCパターンですね...w
Discussion