(React)コンテナー・プレゼンテーションパターンの設計を完全に理解した
概要
業務委託で4社以上経験がありますが、だいたいどこの現場でも、フロントエンドの設計は、「雰囲気で運用している」ことが多かったです。
なので、今回はコンテナープレゼンテーションパターンについて、本気出して考えてみました!
Reactの設計についてはこの動画がよかったです!
コンテナープレゼンテーションパターンは、この記事が一番しっくりきました。
React において関心の分離 (separation of concerns) を実現する方法の一つとして、コンテナ・プレゼンテーションパターン (Container/Presentational pattern) があります。このパターンにより、ビューをアプリケーションロジックから分離することができます。
たとえば、6 枚の犬の画像を取得し、それらを画面に表示するアプリケーションを作成したいとします。
このプロセスを 2つに分けることで、関心の分離を図りたいと思います。
プレゼンテーションコンポーネント:
データがユーザーにどのように表示されるかを管理するコンポーネント。この例では、犬の画像のリストをレンダリングします。コンテナコンポーネント:
何のデータがユーザーに表示されるかを管理するコンポーネント。この例では、犬の画像を取得します。
イメージ
残念ながら、実際はもっと複雑なケースが多いです。
今回は、上記の記事から派生して少し複雑なケースの実装について明らかにしていきます。
前提
Next.js(App Router)は想定外とします。
プレゼンテーションから、サーバーコンポーネント呼び出すパターンもありえるらしいので。
対象読者
- フロントエンド開発中の方
- 設計についてあれこれ考えたい方
いいね!してね
この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!
それでは以下が本編です。
結論
- ドメインモデルを定義して、原則的に最小単位とする
- コンテナープレゼンテーション間のI/Fを厳密に定義する。
説明すること
- atomic designとの対応関係
- 複雑なユースケースを実装する
- ユースケースを分解して説明する
atomic designとの対応関係
一旦、atomic designベースでの対応表を見た方が、イメージしやすいかもしれません。
※chatGPTの回答を少し修正した一般的な例です。
今回対象になるのは、主にOrganisms、Moleculesという認識です。
レイヤー | 説明 | APIリクエストの実行可否 | コンテナー・プレゼンテーションパターン | フォルダー名の例 | 同レイヤーを参照可能 |
---|---|---|---|---|---|
Pages | 完成されたページ全体、ユーザーが実際にアクセスする部分。 | ◯ ページの初期ロードなどで実行する場合も | コンテナー |
pages , screens , views
|
◯ 可能 |
Templates | ページのレイアウトや構造を定義するコンポーネント。 | △ 必要に応じてOrganismsの結果を使用する | コンテナー |
templates , layouts
|
◯ 可能 |
Organisms | 複数のMoleculesやAtomsを組み合わせた複雑なUIコンポーネント。 | ◯ APIリクエストを実行することが多い | コンテナー |
organisms , widgets
|
◯ 可能 |
Molecules | 複数のAtomsを組み合わせたコンポーネント(例: フォーム)。 | ✕ 実行しない | プレゼンテーション |
molecules , parts
|
◯ 可能 |
Atoms | ボタン、ラベル、入力フィールドなどの最小単位のUI要素。 | ✕ 実行しない | プレゼンテーション |
atoms , elements
|
✕ 不可 |
複雑なユースケースを実装する
実際の要件でありそうなユースケースを用意しました。
ユースケース
要件
- APIでメンバーリストを取得したのち、メンバーのステータスに応じて別のステータスに変更できます。
- アクションボタンを押すことで、ステータスを変更できます(APIを呼びます)
- 「アクティブ」ステータス → 「BAN」「Disable」ステータスに変更できます。
- 「アクティベーション中」ステータス → 「BAN」ステータスに変更できます。
UI
- リストはテーブル形式で表示します
- 行ごとのアクションボタン押下 -> ポップアップ -> dialog (+ form)
ストーリーブック(動作確認用)
ソースコード
依存関係図
制約をつける
typescriptを使って、下記の制約を設けました。
- レイヤーで処理を制約する
3層(リポジトリ、ユースケース、ビュー)に分ける
ビュー内をさらに分ける - ドメインモデルで型を制約する
フロントエンドのアプリケーション用のモデルを定義する(特定の状態の時だけ処理をするという要求に応える) - propsで型を制約する
graphql Fragmentを使わない(graphqlの型をそのまま使うとキツいことが多い)
モデルの一部のプロパティのみを渡さない(原則、ドメインモデルが最小単位)
型の再定義をしない(特に関数!) - コンテナープレゼンテーション間のI/F制約をする(callback関数 + EventType)
これらに触れつつ、「データ取得」「表示」「イベント発火」までの流れを具体的なコードとともに説明していきます。
ユースケースを分解して説明する
下記の順番に説明します。
- コンテナー内でhooksを使ってデータを取得する
- コンテナーからプレゼンテーション(ルート)にデータを渡す
- プレゼンテーション(ルート)からプレゼンテーションにデータを渡す
- プレゼンテーションでイベントを発火させて、データを上位コンポーネントに渡す
1.コンテナー内でhooksを使ってデータを取得する
該当箇所
1.1 コンテナーでユースケース(hooks)を使ってメンバーリストを取得します。
下記のようにステータスごとにモデルを作っておき、AllMembers
というリストを返すようにしています。
import type { ActiveMember } from "./activeMember";
import type { BannedMember } from "./bannedMember";
import type { PendingActivationMember } from "./pendingActivationMember";
import type { ResignMember } from "./resignMember";
import type { RestoredMember } from "./restoredMember";
// ステータスごとにユニオンで増えていく
export type AllMember =
| ActiveMember
| ResignMember
| BannedMember
| RestoredMember
| PendingActivationMember;
export type AllMembers = AllMember[];
1.2 モデルへの変換
GraphQLはxx.graphql
ファイルからhooksを生成する時点で「ドメインモデル」と捉えて、リポジトリ層内で変換処理をしています(REST APIの場合はユースケース層で変換するのかな、、?)
※併せて、この層で「graphql特有のdataを取得完了するまでnullableになる型」を潰しています
import type { ActiveMember } from "@/core/domains/member/activeMember";
import type { BannedMember } from "@/core/domains/member/bannedMember";
import type { AllMembers } from "@/core/domains/member/member";
import type { PendingActivationMember } from "@/core/domains/member/pendingActivationMember";
import type { ResignMember } from "@/core/domains/member/resignMember";
import type { RestoredMember } from "@/core/domains/member/restoredMember";
import type { GetAllMembersQueryResult } from "@/generated/graphql";
export const transform = (res: GetAllMembersQueryResult): AllMembers | null => {
// 取得完了するまでnullableになる型を潰す
if (res.data == null) {
return null;
}
// 取得完了するまでnullableになる型を潰す
if (res.data.memberStatusActivityLatest == null) {
return null;
}
const result = res.data.memberStatusActivityLatest.map((activity) => {
if (activity?.memberActive) {
const memberActive: ActiveMember = {
status: "active",
statusActivityId: activity.memberActive.statusActivityId,
memberId: activity.memberActive.memberId,
email: activity.memberActive.email,
address: activity.memberActive.address,
postalCode: activity.memberActive.postalCode,
createdAt: new Date(activity.memberActive.createdAt),
birthday: new Date(activity.memberActive.birthday),
};
return memberActive;
}
if (activity?.memberBanned) {
const memberBanned: BannedMember = {
...activity.memberBanned,
status: "banned",
createdAt: new Date(activity.memberBanned.createdAt),
};
return memberBanned;
}
if (activity?.memberPendingActivation) {
const memberPendingActivation: PendingActivationMember = {
...activity.memberPendingActivation,
status: "pendingActivation",
createdAt: new Date(activity.memberPendingActivation.createdAt),
};
return memberPendingActivation;
}
if (activity?.memberResigned) {
const memberResigned: ResignMember = {
...activity.memberResigned,
status: "resigned",
reasonDetail: activity.memberResigned.reasonDetail ?? undefined,
createdAt: new Date(activity.memberResigned.createdAt),
};
return memberResigned;
}
if (activity?.memberRestored) {
const memberRestored: RestoredMember = {
...activity.memberRestored,
status: "restored",
createdAt: new Date(activity.memberRestored.createdAt),
};
return memberRestored;
}
return undefined;
});
return result.filter((e) => !!e);
};
ドメインモデルに変換する理由
フロントエンドはバックエンドAPIとは異なるドメインでロジックを組み立てるためです。
バックエンドAPIは基本的にstatusごとに型をつけて返してきません。
なので、GraphQLのレスポンス型(fragmentを含む)をそのまま使うのはNGと思っています
// バックエンドAPIが返すmembersのイメージ
apiResponse = {
data:[
// 認証完了前ステータス
{
status: "pendingActivation",
statusActivityId: "1",
memberId: "1",
},
// アクティブステータス
{
status: "active",
statusActivityId: "1",
memberId: "1",
address: "東京都",
postalCode: "123456",
},
]
}
今回のようにstatusごとに次のアクションが異なるから表示するUIを変える場合、
フロントエンド側でドメインモデル(つまり型)を定義しておくべきです。
※こうしておくと、型定義と同じファイルにロジックを集約できます。
import { AllMember } from "./member";
import type { memberStatus } from "./status";
// DBから取得したentityを扱うための型定義
export type ActiveMember = {
status: (typeof memberStatus)["active"];
statusActivityId: string;
memberId: string;
address: string;
postalCode: string;
email: string;
birthday: Date;
createdAt: Date;
};
// TODO: 型定義と同じファイル内にドメインロジックをかく例
export const updateStatus = (member: AllMember, status: memberStatus) => {
member.status = "active";
return member;
}
リポジトリ層(or ユースケース層)でモデルの型を定義して変換しておくと、後続処理(ユースケース、ビュー)で型制限できます。今後の要件追加や変更時に対応しやすくなるでしょう。
export type AllMember =
| ActiveMember
| ResignMember
| BannedMember
| RestoredMember
| PendingActivationMember
... ステータスのパターンが増えるかもしれない
... ステータス x 別のプロパティで判定したくなったときは専用の型を作る
補足資料: 「一休」さんの登壇資料
2.コンテナーからプレゼンテーション(ルート)にデータを渡す
よくある例です。
import type { FC } from "react";
import { useFetchAllMembers } from "@/core/usecases/member/useFetchAllMembers.query";
import { MemberTableRow } from "./table/MemberTableRow";
import { MemberTable } from "./table/Table";
import { type OnSubmitStatusChange, eventTypes } from "./table/type";
export const IndexTemplate: FC = () => {
const { data, loading } = useFetchAllMembers();
if (loading) {
return <div>loading...</div>;
}
if (data?.members === null) {
return (
<div data-testid="admin-members-index-error">
CSR リクエストエラー: データ取得に失敗しました
</div>
);
}
// Event
const onSubmit: OnSubmitStatusChange = (payload) => {
if (payload.type === eventTypes.onClickBan) {
console.log("API処理", payload.detail);
} else if (payload.type === eventTypes.onClickDisable) {
console.log("API処理", payload.detail);
}
};
return (
<div data-testid="admin-members-index">
<MemberTable>
{data.members.map((member) => (
<MemberTableRow
member={member} // AllMember型
key={member.statusActivityId}
onSubmit={onSubmit}
/>
))}
</MemberTable>
</div>
);
};
プレゼンテーション(ルート)側が定義したI/Fに従って、コンテナー内にonSubmit関数を定義します。
if文で条件分岐した際に、キーバリュー(type, value)の組み合わせで型推論が効くようにしましょう。
// ルートとのやりとりは、type, detailのI/Fで型制約する。
const onSubmit: OnSubmitStatusChange = (payload) => {
if (payload.type === eventTypes.onClickBan) {
// detailの型 OnClickBanValue が推論される
console.log("API処理", payload.detail);
} else if (payload.type === eventTypes.onClickDisable) {
// detailの型 OnClickDisableValue が推論される
console.log("API処理", payload.detail);
}
};
NG: 引数(や返り値)にだけ型をつけるケースはダメです。
(props側で再度型定義することになる、型をimportしたり複雑になるため)
// 引数にだけ型をつけるケース
const onSubmit: OnSubmitStatusChange = (payload: XXXX): YYY => {
上記の型定義は少し大変だけど、書くとロジックがシンプルになります。
イベントタイプが増えたら、キーバリューの組み合わせを増やす想定です。
import type { ActiveMember } from "@/core/domains/member/activeMember";
import type { PendingActivationMember } from "@/core/domains/member/pendingActivationMember";
import type { BanMemberSchema } from "../form/useBanMemberForm";
//
// Payload
//
export type EventType = (typeof eventTypes)[keyof typeof eventTypes];
export const eventTypes = {
onClickBan: "onClickBan",
onClickDisable: "onClickDisable",
} as const;
type OnClickBanValue = {
member: ActiveMember | PendingActivationMember;
reason: string;
};
type OnClickDisableValue = {
member: ActiveMember;
};
type EventValues = {
[eventTypes.onClickBan]: OnClickBanValue;
[eventTypes.onClickDisable]: OnClickDisableValue;
};
type BasePayload<T extends EventType, U extends EventValues[T]> = {
type: T;
detail: U;
};
type BanPayload = BasePayload<"onClickBan", OnClickBanValue>;
type DisablePayload = BasePayload<"onClickDisable", OnClickDisableValue>;
type Payload = BanPayload | DisablePayload;
//
// Event Handler
//
export type OnSubmitStatusChange = (payload: Payload) => void;
// ...割愛
3.プレゼンテーション(ルート)からプレゼンテーションにデータを渡す
いくつかファイルを抜粋して、紹介します。詳しく見たい人はソースコードを見てください。
関数レベルで型定義したので、プレゼンテーション(子)側では型定義が楽になりました。
type Prop = {
member: AllMember;
onSubmit: OnSubmitStatusChange; // 関数に対する型を再定義しなくて済むようになった
// onSubmit: (payload: Event) => void; みたいに都度再定義するパターンをよく見かける
};
export const MemberTableRow: FC<Prop> = ({ member, onSubmit }) => {
関数の型を指定する際にGenerics部分に型を入れるだけになりました!
export const MemberTableRow: FC<Prop> = ({ member, onSubmit }) => {
const [dialogState, setToggleModal] =
useState<DialogState>(initialDialogState);
const openModal = useCallback((eventType: EventType) => {
setToggleModal((prev) => ({ ...prev, [eventType]: true }));
}, []);
const closeModal = useCallback((eventType: EventType) => {
setToggleModal((prev) => ({ ...prev, [eventType]: false }));
}, []);
// Banのクリック処理はActiveMemberのみを対象としている
const onClickBan: OnClickBan<ActiveMember | PendingActivationMember> = () => {
openModal("onClickBan");
};
// disableのクリック処理はActiveMemberのみを対象としている
const onClickDisable: OnClickDisable<ActiveMember> = () => {
openModal("onClickDisable");
};
// Banのクリック処理はActiveMember or PendingActivationMemberを対象としている
const onSubmitBan: OnSubmitBan<ActiveMember | PendingActivationMember> = (
member,
data,
) => {
closeModal("onClickBan");
onSubmit({
type: eventTypes.onClickBan,
detail: {
member,
reason: data.reason,
},
});
};
// Banのクリック処理はActiveMember を対象としている
const onSubmitDisable: OnSubmitDisable<ActiveMember> = (member) => {
return () => {
closeModal("onClickDisable");
onSubmit({
type: eventTypes.onClickDisable,
detail: { member },
});
};
};
propsの型定義がすごく簡単に済みます
import { Button, Dialog } from "@/components";
import { DialogBody } from "@/components/dialog/Dialog";
import type { ActiveMember } from "@/core/domains/member/activeMember";
import type { OnSubmitDisable } from "../table/type";
type props = {
opened: boolean;
member: ActiveMember; // ドメインモデルが渡ってくる
onSubmitDisable: OnSubmitDisable<ActiveMember>; // Genericsに型を指定するだけですむ
onClose: () => void;
};
export const DisableDialog = ({
opened,
member,
onSubmitDisable,
onClose,
}: props) => {
return (
<Dialog open={opened} handleClose={onClose}>
<DialogBody>
{/* bodytitle */}
<h2
className="text-std-24B-5 desktop:text-std-28B-5"
id="example-heading1"
>
Disable status is:{member.status}
</h2>
{/* body */}
<p className="text-std-16N-7">
ダイアログの補助テキストが入ります。ダイアログの補助テキストが入ります。
</p>
{/* form */}
<div className="mt-2 flex w-full max-w-xs flex-col gap-4 desktop:mt-6">
{/* action */}
<Button onClick={onSubmitDisable(member)} size="lg" variant="primary">
無効化する
</Button>
<Button onClick={onClose} size="lg" variant="tertiary">
キャンセル
</Button>
</div>
</DialogBody>
</Dialog>
);
};
モデルの一部のプロパティのみを渡さない。つまり (原則、ドメインモデルを最小単位) としたいです。
NG: モデルの一部のプロパティのみを渡すケース
// ドメインモデルを最小単位としたケース
const onSubmitDisable: OnSubmitDisable<ActiveMember> = (member) => {
return () => {
closeModal("onClickDisable");
onSubmit({
type: eventTypes.onClickDisable,
detail: { member },
});
};
};
// 引数にモデルの一部プロパティだけを渡すケース(NG)
const onSubmitDisable = (memberId: string) => {
return () => {
closeModal("onClickDisable");
onSubmit({
type: eventTypes.onClickDisable,
detail: { member },
});
};
};
まとめ
厳密にI/Fを決めることで、全体的にプレゼンテーション処理がシンプルになる一方で、型定義ファイルがどんどんボリューミーになっていきました。
で、最終的に
「型定義すること」=「実装」では? という気づきがありました。
ライブラリを作る時(使う時)と近い感覚でしょうか。
ただ、型定義でつまづくと開発が先に進まないので、Typescriptに精通したエンジニアがいないと厳しいかもしれません。
実際は今回の例よりもっと複雑なパターン(コンテナーからコンテナーを呼ぶとか、コンテナーから複数のプレゼンテーションルートを呼ぶとか)が多い印象です。
そのときは今回の例をベースに考えていきたいですね!
この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!
Discussion