React × container / presenter パターンの子コンポーネント実装の仕方で悩んだこと

プログラミング

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パターンにする必要はないと判断しました。
とはいえ、「このパターンに従って実装している」ということが分かれば第三者がコードを読む際の取っ掛かりになるメリットもあると思うので、もしどこかで使う機会があれば、思い出そうと思います。

コメント

タイトルとURLをコピーしました