Container / Presentationalパターン分割指南書
記事を書く背景
最近入った現場でContainer/Presentationalパターンを導入しました。
可読性向上やテスト(Storybook)作成容易性など多くの恩恵があると感じています。一方でビューとロジックの分離が綺麗にいかないこともあり、これはどう分割すれば良いのだろうと悩むことも多く、世に記事も見当たらなかったので実践して分かったことを整理してみました。
Container/Presentational基礎
基本的な思想やメリットは以下の記事に委ねます。極めて分かりやすく、入門に最適です。
誤った分割の仕方
Container/Presentationalパターンの原則は、ロジックをContainer、ビューをPresentationalに記述することですが、実践していると完全に分離することが難しいケースに当たります。
(厳密には分離はできますが、無駄にファイルが増えるだけで責務が分かりにくくなる)
ContainerにUIが混ざる
ロジックをビューから追い出すことに意識を向けた結果、以下のようなコンポーネントが生まれました。
export const InfoCardContainer: FC<Props> = (props) => {
return (
<>
{props.status === ProposalStatus.Approved && (
<Card>承認を伝えるテキスト文言</Card> // 実際よりも単純化させてます
)}
{props.status === ProposalStatus.Rejected && (
<Card>棄却を伝えるテキスト文言</Card>
)}
〜〜〜同じような分岐〜〜〜
</>
);
};
文言やスタイルはUIの関心ごとであり、Containerに持たせるべきではありません。
Presentationalに業務ロジックが混ざる
Presentationalに業務ロジックが記載されるケースも見受けられました。
〜〜〜中略〜〜〜
{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コンポーネントを呼び出す形にします。
export const InfoCardContainer: FC = () => {
return (
<>
{props.status === ProposalStatus.Approved && (
<InfoCardApproved />
)}
{props.status === ProposalStatus.Rejected && (
<InfoCardRejected />
)}
〜〜〜同じような分岐〜〜〜
</>
);
};
export const InfoCardApproved: FC<Props> = (props) => {
return (
<Card>承認を伝えるテキスト文言</Card>
);
};
Presentationalに業務ロジックが混ざっていたパターン
分岐は極力Container側で単純化させた上でPropsとして渡してあげます。
Presentationalには極力仕事をさせない、知識を持たせない形を目指します。
export const xxxDetail: FC<Props> = ({
〜〜〜中略〜〜〜
isActionable,
}) => {
〜〜〜中略〜〜〜
{isActionable && (
<Stack gap="sm">
<ApproveButtonContainer proposalID={proposalID} />
<RejectButtonContainer proposalID={proposalID} />
</Stack>
)}
〜〜〜中略〜〜〜
Presentationalに文言を持たせる場合
以下のコンポーネントのように、一箇所文言が異なるような場合はPresentationalで分岐を入れてます。
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
/>
</>
);
};
コンポジションの推奨
以下のようにコンポジションを活用すると、綺麗に分離することができます。
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>
);
};
type Props = {
children: JSX.Element[];
};
export const Contracts: FC<Props> = ({ children }) => {
return (
<div className={classes.wrapper}>
<Stack gap="sm">{children}</Stack>
</div>
);
};
コンポジションはパフォーマンス面でも恩恵があり、積極的に活用したい手法です。
ルールの例外について
Formコンポーネントに関しては、Presentationに状態を持たせる形にしています。
これはバリデーションやフォーム操作などの責務範囲はPresentation側にあると考えられること、実装的に自然になることから、このように定義しています。
ただし、例えばフォームの初期値をフェッチで取得したデータから変換するなどの処理は、関数としてコンポーネント外に切り出すなど肥大化しないようにしています。
まとめ
Container/PresentationalパターンはRSC時代のReactにおいても、非常に有用なパターンとして使われていくと思います。
適用方法は単純なようで、実践すると悩みどころが多く、チームごとに認識を合わせてルール決めをすることをお勧めします。
Discussion