container / presenterパターンについて、概念を知った後ですぐに実装を始めて、迷子状態だったのもあり、「これどっちがパターンとしての実装として正しいのだろう?」と悩んでばかり。そんな実装の迷いと、どういう理由で最終的にどの実装をとったのか、備忘として残します。
Page(親)コンポーネントに子コンポーネントを実装する場合
例えば、ページそのものをPageコンポーネントとして定義した際に、その中に配置されることとなる、子コンポーネントについて、どのように扱ってあげればよいかわからず、以下の2つの手法のどちらを取るか迷っていました。
親PresenterでReactNode型の子Containerを受け取る方法
// 親Presenter
import { Grid, Row } from "@carbon/react";
import { ReactNode } from "react";
export interface SamplePagePresenterProps {
children: ReactNode
}
export const SamplePagePresenter = (props: SamplePagePresenterProps) => {
return (
<div className="product-grid-container">
<Grid>
<Row>
{props.children}
</Row>
</Grid>
</div>
);
};
// 親Container
import { SamplePagePresenter } from "./b-SamplePagePresenter";
import { ProductCardContainer } from "./b-ProductCardContainer";
// import { useQuery } from "@tanstack/react-query";
// import axios from "axios";
const useGenerateProps = () => {
const dummyData = [
{
id: "1",
name: "Product 1",
description: "Description of Product 1",
price: 100,
imageUrl: "",
},
];
return {
products: dummyData || [], // dataがfalsyな値の場合にデフォルトで空配列を返す
};
};
export const SamplePageContainer = () => {
const props = useGenerateProps();
console.log(props);
return (
<SamplePagePresenter>
{props.products.map((product) => (
<ProductCardContainer key={product.id} product={product} />
))}
</SamplePagePresenter>
);
};
// 子Presenter
import { Button, Tag, Tile } from "@carbon/react";
export interface Product {
id: string;
name: string;
description: string;
price: number;
imageUrl: string;
}
export interface ProductCardPresenterProps {
product: Product;
imageLoaded: boolean;
}
export const ProductCardPresenter = (props: ProductCardPresenterProps) => {
return (
<Tile>
<div className="product-image-container">
{props.imageLoaded ? <img src={props.product.imageUrl} alt={props.product.name} className="product-image" /> : <div className="placeholder-image" />}
</div>
<div className="product-info">
<p>{props.product.name}</p>
<p>{props.product.description}</p>
<p>Price: {props.product.price}</p>
</div>
<div>
<Tag>test</Tag>
</div>
<div>
<Button>Amazon</Button>
</div>
</Tile>
);
};
// 子Container
import { useState, useEffect } from "react";
import { ProductCardPresenter } from "./b-ProductCardPresenter";
import { Product } from "./b-ProductCardPresenter";
const useGenerateProps = ( product: Product ) => {
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
const image = new Image();
image.src = product.imageUrl;
image.onload = () => {
setImageLoaded(true);
};
}, [product.imageUrl]);
return {
product: product,
imageLoaded: imageLoaded,
};
};
export const ProductCardContainer: React.FC<{ product: Product }> = ({ product }) => {
const props = useGenerateProps(product);
return <ProductCardPresenter {...props} />;
};
メリット
・親コンポーネントの関心事が分離されます。子コンポーネントの具体的な実装に依存しません。
デメリット
・propsの肥大化の可能性がある(hooksやuseContextで解決はできる)
子コンポーネントContainerを親Presenterで直接インポートする方法
// 親Presenter
import { Grid, Row, Column } from "@carbon/react";
import { ProductCardContainer } from "../ProductCardContainer";
import { Product } from "../ProductCardPresenter";
export interface SamplePagePresenterProps {
products: Product[];
}
export const SamplePagePresenter = (props: SamplePagePresenterProps) => {
return (
<div className="product-grid-container">
<Grid>
<Row>
{props.products.map((product) => (
<Column key={product.id} sm={4} md={3} lg={8}>
<ProductCardContainer product={product} />
</Column>
))}
</Row>
</Grid>
</div>
);
};
// 親Container
import { SamplePagePresenter, SamplePagePresenterProps } from "./a-SamplePagePresenter";
// import { useQuery } from "@tanstack/react-query";
// import axios from "axios";
const useGenerateProps = (): SamplePagePresenterProps => {
const dummyData = [
{
id: "1",
name: "Product 1",
description: "Description of Product 1",
price: 100,
imageUrl: "",
}
];
return {
products: dummyData || [], // dataがfalsyな値の場合にデフォルトで空配列を返す
};
};
export const SamplePageContainer = () => {
const props = useGenerateProps();
console.log(props);
return <SamplePagePresenter {...props} />;
};
// 子Presenter
import { Button, Tag, Tile } from "@carbon/react";
export interface Product {
id: string;
name: string;
description: string;
price: number;
imageUrl: string;
}
export interface ProductCardPresenterProps {
product: Product;
imageLoaded: boolean;
}
export const ProductCardPresenter = (props: ProductCardPresenterProps) => {
return (
<Tile>
<div className="product-image-container">
{props.imageLoaded ? <img src={props.product.imageUrl} alt={props.product.name} className="product-image" /> : <div className="placeholder-image" />}
</div>
<div className="product-info">
<p>{props.product.name}</p>
<p>{props.product.description}</p>
<p>Price: {props.product.price}</p>
</div>
<div>
<Tag>test</Tag>
</div>
<div>
<Button>Amazon</Button>
</div>
</Tile>
);
};
// 子Container
import { useState, useEffect } from "react";
import { ProductCardPresenter, ProductCardPresenterProps } from "./a-ProductCardPresenter";
import { Product } from "./a-ProductCardPresenter";
const useGenerateProps = ( product: Product ): ProductCardPresenterProps => {
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
const image = new Image();
image.src = product.imageUrl;
image.onload = () => {
setImageLoaded(true);
};
}, [product.imageUrl]);
return {
product: product,
imageLoaded: imageLoaded,
};
};
export const ProductCardContainer: React.FC<{ product: Product }> = ({ product }) => {
const props = useGenerateProps(product);
return <ProductCardPresenter product={props.product} imageLoaded={props.imageLoaded} />;
};
メリット
・子コンポーネントの使い方が明確で、コード上で直接的に見えます。
・子コンポーネントへのデータの受け渡しが簡潔になります。
デメリット
・親コンポーネントが特定の子コンポーネントに依存してしまいます。レイアウトが変わったりすると、大きく変更が必要になる可能性があります。
結局どちらにしたか
上記で挙げたように、どちらにもメリット・デメリットがあります。最終的にページ設計とコンポーネント責務により決定するのが良いと思います。今回の場合、
・ページコンポーネントは、ある特定の役割を持つページとして個々に親コンポーネントとして定義
・レイアウトは将来的にも大きく変更されることがない(予定)
だったため、2つめの「子コンポーネントContainerを親Presenterで直接インポートする方法」を取ることにしました。
後日
とここまで書きましたが、そもそも、container / presenterパターンで実装しようと思ったモチベーションが
・既存のアプリが本パターンで実装されていたため、概念を学びたかった
・ロジックとUIの関心の分離
・開発しようとしているものが小規模である
だったので、hooksがあるReactや、GraphQLやTanStackの使い方的にも、container / presenterパターンにする必要はないと判断しました。
とはいえ、「このパターンに従って実装している」ということが分かれば第三者がコードを読む際の取っ掛かりになるメリットもあると思うので、もしどこかで使う機会があれば、思い出そうと思います。
コメント