🐕

Container / Presentationalパターン分割指南書

2024/05/30に公開

記事を書く背景

最近入った現場でContainer/Presentationalパターンを導入しました。
可読性向上やテスト(Storybook)作成容易性など多くの恩恵があると感じています。一方でビューとロジックの分離が綺麗にいかないこともあり、これはどう分割すれば良いのだろうと悩むことも多く、世に記事も見当たらなかったので実践して分かったことを整理してみました。

Container/Presentational基礎

基本的な思想やメリットは以下の記事に委ねます。極めて分かりやすく、入門に最適です。
https://zenn.dev/buyselltech/articles/9460c75b7cd8d1

誤った分割の仕方

Container/Presentationalパターンの原則は、ロジックをContainer、ビューをPresentationalに記述することですが、実践していると完全に分離することが難しいケースに当たります。
(厳密には分離はできますが、無駄にファイルが増えるだけで責務が分かりにくくなる)

ContainerにUIが混ざる

ロジックをビューから追い出すことに意識を向けた結果、以下のようなコンポーネントが生まれました。

InfoCardContainer.tsx
export const InfoCardContainer: FC<Props> = (props) => {
  return (
    <>
      {props.status === ProposalStatus.Approved && (
        <Card>承認を伝えるテキスト文言</Card> // 実際よりも単純化させてます
      )}
      {props.status === ProposalStatus.Rejected && (
        <Card>棄却を伝えるテキスト文言</Card>
      )}
     〜〜〜同じような分岐〜〜〜
    </>
  );
};

文言やスタイルはUIの関心ごとであり、Containerに持たせるべきではありません。

Presentationalに業務ロジックが混ざる

Presentationalに業務ロジックが記載されるケースも見受けられました。

xxxDetail.tsx
      〜〜〜中略〜〜〜
      {status === ProposalStatus.Submitted && (
        <Stack gap="sm">
          <ApproveButtonContainer proposalID={props.proposalID} />
          <RejectButtonContainer proposalID={props.proposalID} />
        </Stack>
      )}
      〜〜〜中略〜〜〜

分割する上でのルール

上記のように実践していると、Containerにビュー、Presentationalにロジックが混ざりがちです。
これらを絶対禁止にすると、逆に可読性が悪く、開発生産性が下がる結果に陥ります。
そのため、Container/Presentationalパターンのエッセンスを踏まえて、
可読性・再利用性の高さ、ルールの遵守しやすさから以下のルールを設けることにしました。

  • ContainerにUIの関心ごと(スタイルや文言など)を持たせない
    • Presentationalにロジックを持たせない、よりルール遵守が容易なため
  • Presentationalでの分岐は単純(ビュー側の関心ごと)なものにする
    • propsを使っての単純分岐であればOK
  • 文言だけでの分岐であればコンポーネント化しなくてOK
    • 文言だけのコンポーネントに価値(再利用性)はないと考えており、可読性の観点からコンポーネント化はしない
  • ロジックの切り出しだけの理由でコンポーネント分割しない
    • ビューとしてコンポーネント化するものでもなければ、コンポーネント化しない。ロジックだけの切り出しであればカスタムhooksを使う

ルールに則った改善例

先ほどのアンチ例を改善した形は以下になります。

ContainerにUIが混ざっていたパターン

Containerで文言、スタイルを記述するのではなく、Presentationalコンポーネントを呼び出す形にします。

InfoCardContainer.tsx
export const InfoCardContainer: FC = () => {
  return (
    <>
      {props.status === ProposalStatus.Approved && (
        <InfoCardApproved />
      )}
      {props.status === ProposalStatus.Rejected && (
        <InfoCardRejected />
      )}
     〜〜〜同じような分岐〜〜〜
    </>
  );
};
InfoCardApproved.tsx
export const InfoCardApproved: FC<Props> = (props) => {
  return (
    <Card>承認を伝えるテキスト文言</Card>
  );
};

Presentationalに業務ロジックが混ざっていたパターン

分岐は極力Container側で単純化させた上でPropsとして渡してあげます。
Presentationalには極力仕事をさせない、知識を持たせない形を目指します。

xxxDetail.tsx
    export const xxxDetail: FC<Props> = ({
      〜〜〜中略〜〜〜
      isActionable,
    }) => {
      〜〜〜中略〜〜〜
      {isActionable && (
        <Stack gap="sm">
          <ApproveButtonContainer proposalID={proposalID} />
          <RejectButtonContainer proposalID={proposalID} />
        </Stack>
      )}
      〜〜〜中略〜〜〜

Presentationalに文言を持たせる場合

以下のコンポーネントのように、一箇所文言が異なるような場合はPresentationalで分岐を入れてます。

xxxTable.tsx
const getWaitingCategoryName = (waitingCategory: waitingCategory) => {
  if (waitingCategory === "review") {
    return "承認";
  } else {
    return "申請";
  }
};

export const xxxTable: FC<Props> = (props) => {
  const waitingCategoryName = getWaitingCategoryName(props.waitingCategory);
  〜〜〜中略〜〜〜
  return (
    <>
      <Title
        level={3}
      >{`xxxxxx${waitingCategoryName}xxxxxx`}</Title>
      <Table
        xxxx
      />
    </>
  );
};

コンポジションの推奨

以下のようにコンポジションを活用すると、綺麗に分離することができます。

ContractsContainer.tsx
export const ContractsContainer: FC = () => {
  const { data } = useSuspenseQuery(ContractsQueryDocument);
  if (data.contracts.length === 0) {
    return <ContractsNone />;
  }
  return (
    <Contracts>
      {data.contracts.map((contract) =>
        contract.__typename === "A" ? (
          <AContractContainer
            key={contract.id}
            A={contract as FragmentType<typeof AContractFragment>}
          />
        ) : (
          <BContractContainer
            key={contract.id}
            B={contract as FragmentType<typeof BContractFragment>}
          />
        ),
      )}
    </Contracts>
  );
};
Contracts.tsx
type Props = {
  children: JSX.Element[];
};

export const Contracts: FC<Props> = ({ children }) => {
  return (
    <div className={classes.wrapper}>
      <Stack gap="sm">{children}</Stack>
    </div>
  );
};

コンポジションはパフォーマンス面でも恩恵があり、積極的に活用したい手法です。
https://zenn.dev/counterworks/articles/react-composition#②-コンポジション(children-props)

ルールの例外について

Formコンポーネントに関しては、Presentationに状態を持たせる形にしています。
これはバリデーションやフォーム操作などの責務範囲はPresentation側にあると考えられること、実装的に自然になることから、このように定義しています。
ただし、例えばフォームの初期値をフェッチで取得したデータから変換するなどの処理は、関数としてコンポーネント外に切り出すなど肥大化しないようにしています。

まとめ

Container/PresentationalパターンはRSC時代のReactにおいても、非常に有用なパターンとして使われていくと思います。
適用方法は単純なようで、実践すると悩みどころが多く、チームごとに認識を合わせてルール決めをすることをお勧めします。

Fivot Tech Blog

Discussion