Presentational層とContainer層の実装例
目次
はじめに
こんにちは!ハイヤールーの共同創業者の谷合です。 ハイヤールーアドベントカレンダーの 5 日目を担当します。
この記事は、1日目に僕が書いた「フロントエンドのコンポーネントの責務分離のための方針とその効果について」という記事における移行前後を具体的なコードを用いて解説する記事となります。
より詳細なコードレベルの話になりますので、もし前述の記事を読んでいないようであれば、先に一読してから読むと、理解しやすい部分もあるかと思いますので、ぜひご参照ください。
実装例
ではさっそくですが、それぞれの方針ごとにどのようなコードになるのかを見ていきます。
Presentational Component と Container Component との分離
以下はユーザー情報(名前とアイコン)を表示、更新する簡易的なコンポーネントです。
従来のアーキテクチャでは、以下の UserGeneral.tsx に UI の表示ロジックも、ユーザー情報の取得も更には、ユーザー情報の更新処理(サーバーリクエスト部分は切り出されていました。)それらもすべてが含まれていました。
その結果、責務が集中しすぎて使い回しの効かないコンポーネントとなってしまっていました。
以前のアーキテクチャ
// UserGeneral.tsx
type DialogProps = {
userName: string;
photoUrl: string;
onClick: () => void;
};
const ImageCropDialog = (props: DialogProps) => {
return <div onClick={props.onClick}>{/* 画像の表示 */}</div>;
};
export const UserGeneral = (props: {userId: string}) => {
const useUser = () => {
// ユーザー情報を取得する処理
return getUser(props.userId);
};
const user = useUser();
const handleCropImage = () => {
// 画像のクロップ処理
// 画像のアップロード処理
};
return (
<div>
<div>{user.name}</div>
<div>{user.photoUrl}</div>
<ImageCropDialog onClick={handleCropImage} userName={props.userName} photoUrl={props.photoUrl}/>
</div>
);
この程度のコンポーネントであれば、処理がそこまで複雑にならないため問題にはなりにくいですが、ひどいケースだと 1 ファイル 1500 行以上のものも存在しており、そうなると中身を読み解くのにかなりの時間が費やされます。またそういったファイルほどテストが書きにくく、変更を行うとデグレが起こる可能性が高くなっていました。
このコードを新しいアーキテクチャでは以下のように 2 つのファイルに分割することができます。
新アーキテクチャ
// Presentational.tsx
export type PresentationalProps = {
userName: string;
photoUrl: string;
imageCropDialog: ImageCropDialogProps;
};
type ImageCropDialogProps = {
onClick: () => void;
};
const ImageCropDialog = (props: ImageCropDialogProps) => {
return <div onClick={props.onClick}>{/* 画像の表示 */}</div>;
};
export const Presentational = (props: PresentationalProps) => {
return (
<div>
<div>{props.userName}</div>
<div>{props.photoUrl}</div>
<ImageCropDialog {...props.imageCropDialog} />
</div>
);
};
// Container.tsx
import { Presentational, PresentationalProps } from "./Presentational";
const useGenerateProps = (): PresentationalProps => {
const getCroppedImage = () => {
// crop処理
};
const handleUploadImage = () => {
const croppedImage = getCroppedImage();
// uploadの処理
};
return {
userName: "keisuke taniai",
photoUrl: "https://hireroo.io/userImages/xxxx",
imageCropDialog: {
onClick: handleUploadImage,
},
};
};
export const Container = () => {
const props = useGenerateProps();
return <Presentational {...props} />;
};
Container 層では Presentational 層で必要な Props を生成します。
Presentational 層は受け取った Props をただ表示する程度に留まるため、UI とビジネスロジックの分離が行われ、それぞれのコンポーネントでテストが書きやすくなり先程の可読性やデグレの問題が軽減されました。
一つ注意が必要なのですが、すべての State を Container 層に持っていくということではなく、あくまでもページ固有の状態を管理する State に関しては(例えば、編集モードと読み取り専用モードの切替えや、メニューを表示するための anchorElement など)引続き Presentational 層に持っておく方が都合がいいケースが多いです。
また、非常に細かいポイントですが子コンポーネントに Props を渡す際に、JavaScript のスプレッド構文を利用することで、Props の数が増えた場合でも JSX と Props をきれいに分離できるため、可読性を損なわないようにしています。
export const Container = () => {
const props = useGenerateProps();
return <Presentational {...props} />;
};
Presentational 層は階層を細分化して再利用可能性を高める
以下のコンポーネントはメンバー一覧を管理するコンポーネントの一部抜粋です。 このページでは、以下の操作が可能です。
- メンバー一覧を確認
- メンバーそれぞれの権限を編集
- メンバーを削除
- グループ(チーム)にメンバーを所属させる
- メンバーを会社に招待
この要件を実現するために、このページコンポーネント(Members.tsx)には以下の子コンポーネントが直接記述されています。
- ページのヘッダー
- アクションボタン(メンバー招待・メンバー削除)
- メンバー一覧を表示するテーブル
- テーブルの要素が持つメニュー
- メンバーの権限を編集するダイアログ
- グループ情報を検索するダイアログ
弊社ではMUIを利用しているためそのままの状態で以下に示します。
// Members.tsx
import * as React from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
export default function Members() {
const [multiDeleteOpen, setMultiDeleteOpen] = React.useState<boolean>(false);
const invitationButtonDidClick = () => {
// 処理
};
const deleteEmployees = () => {
// 削除処理
};
return (
<div>
<div>
<p>メンバー</p>
<div>
<button onClick={invitationButtonDidClick}>招待する</button>
<button onClick={() => setMultiDeleteOpen(true)}>削除する</button>
</div>
</div>
{/* メンバー一覧を表示するテーブルコンポーネント */}
{/* テーブルの一つづつの要素が持つメニュー */}
{/* メンバーを招待するダイアログ */}
{/* メンバーの権限を編集するダイアログ */}
{/* グループ情報を検索するダイアログ */}
<Dialog open={multiDeleteOpen}>
<DialogTitle>メンバーを削除する</DialogTitle>
<DialogContent>{/* 削除予定のメンバーリスト */}</DialogContent>
<DialogActions>
<button onClick={() => setMultiDeleteOpen(false)}>いいえ</button>
<button onClick={deleteEmployees}>はい</button>
</DialogActions>
</Dialog>
</div>
);
}
すべての処理がこのコンポーネントに集約されているため、それぞれをコンポーネントに切り出すことが難しくなっていました。
それ故にコードが肥大化し、可読性が悪くなりこのコンポーネントが何をするものなのか(上記で列挙した要件)を把握しにくいものにしていました。
これらを新しいアーキテクチャでは以下のように切り出しています。
// MemberSettings.tsx
import Box from "@mui/material/Box";
import * as React from "react";
import AddMemberToGroup, {
AddMemberToGroupProps,
} from "./DetailSettings/AddMemberToGroup";
import DeleteMember, { DeleteMemberProps } from "./DetailSettings/DeleteMember";
import DeleteMembers, {
DeleteMembersProps,
} from "./DetailSettings/DeleteMembers";
import EditRole, { EditRoleProps } from "./DetailSettings/EditRole";
import InviteMember, { InviteMemberProps } from "./DetailSettings/InviteMember";
import EditMenu, { EditMenuProps } from "./EditMenu/EditMenu";
import MemberTable, { MemberTableProps } from "./MemberTable/MemberTable";
import { MemberSettingsProvider } from "./PrivateContext";
import SettingsHeader, {
SettingsHeaderProps,
} from "./SettingsHeader/SettingsHeader";
export type MemberSettingsProps = {
settingsHeader: SettingsHeaderProps;
memberTable: MemberTableProps;
inviteMember: InviteMemberProps;
editRole: EditRoleProps;
deleteMember: DeleteMemberProps;
deleteMembers: DeleteMembersProps;
addMemberToGroup: AddMemberToGroupProps;
editMenu: EditMenuProps;
};
const MemberSettings: React.FC<MemberSettingsProps> = (props) => {
return (
<MemberSettingsProvider>
<Box sx={{ width: "100%" }}>
<SettingsHeader {...props.settingsHeader} />
<Box display="flex" mt={3}>
<MemberTable {...props.memberTable} />
</Box>
<DeleteMembers {...props.deleteMembers} />
<EditRole {...props.editRole} />
<InviteMember {...props.inviteMember} />
<DeleteMember {...props.deleteMember} />
<AddMemberToGroup {...props.addMemberToGroup} />
<EditMenu {...props.editMenu} />
</Box>
</MemberSettingsProvider>
);
};
export default MemberSettings;
それぞれのコンポーネントをパーツごとに切り出すことで、親コンポーネントからの呼び出し部分ではシンプルに各パーツを呼び出すだけに留まっています。
そうすることで親コンポーネントから子コンポーネントで利用するロジックも消えるため、かなりスッキリしました。
先程の「**Presentational 層と Container 層を分離する」**方針によって、Props は基本的には Container 層に移動しているため、こちらでも子コンポーネントに直接 Props を渡すというくらいにシンプルになっています。
また、MemberSettings で利用されている子コンポーネントの一例を見てみます。
こちらもPrimitive として切り出されたコンポーネント(BaseDialog.tsx)に親から受け取った Props をマッピングしているだけということが分かります。
各コンポーネントの責務がはっきりと分けられており、どのコンポーネントがどんな役割を持っているか、どんな Props が渡ってきているかという責務の理解の面や、バグが発生した際に影響範囲の切り分けという面で、優れたコンポーネントとなりました。
// DeleteMemberDialog.tsx
import BaseDialog, {
BaseDialogProps,
} from "../../../primitive/BaseDialog/BaseDialog";
import { useMemberSettingsContext } from "../PrivateContext";
export type DeleteMemberProps = {
employeeName: string;
onSubmit?: (field: { memberId: number }) => void;
};
const DeleteMember: React.FC<DeleteMemberProps> = (props) => {
const { closeDialog, currentSelectedMemberId } = useMemberSettingsContext();
const baseDialogProps: BaseDialogProps = {
// propsを生成
onClickYes: () => {
if (currentSelectedMemberId) {
props.onSubmit?.({ memberId: currentSelectedMemberId });
}
closeDialog();
},
};
return (
<BaseDialog {...baseDialogProps}>
<DialogContentText>{/* 削除時のメッセージ */}</DialogContentText>
</BaseDialog>
);
};
export default DeleteMember;
また以前のアーキテクチャでは親コンポーネントが持っていたページ固有の State やそれを操作する関数などは Context を利用することで UI コンポーネントから分離しています。
const { closeDialog, currentSelectedMemberId } = useMemberSettingsContext();
例えば、ダイアログのコンポーネントの開閉状態や操作する選択されたメンバー ID などは Context を介してやり取りしています。
仮に親コンポーネントがこの状態を持ってしまうと、子コンポーネントにコールバックとして処理の最後にダイアログを閉じる処理を渡さなくてはならなくなったり、逆に子コンポーネントに閉じ込めようとすると今度は、コンポーネントの中で開閉イベントを管理しなくてはなりません。
今回のケースでは、ページ内のヘッダーが持つボタンによってダイアログを表示させ、処理が終わったタイミングでダイアログを閉じるというユースケースを実現するためには、この形で処理を持つことで、Presentatinal な責務にとどめつつ処理を別で剥がすことが可能になりました。
Container 層の責務ごとの切り出しについて
最後に Container 層について解説したいと思います。
この節では、Container 層を構成する 3 つのファイルセットと、Page と Widget という2 種類の Container Component の振る舞いについて、説明します。
Container 層を構成する 3 つのファイルセット まず、3 つのファイルセットですが、以前の記事でも述べたように Container 層は大きく以下の役割ごとにファイルを分けています。
- FetchContainer初期値が必要なページの場合の、初期 fetch を行うコンポーネント初期値が取得できなかった場合(やエラーが発生した場合)はこのコンポーネントの ErrorBoundary で Catch する
- ContainerPresentational 層の Page を返すだけのコンポーネント必要な Props は以下の GenerateProps から受け取る
- useGeneratePropsPage が必要な Props を生成、取得する処理を分離したカスタムフック Props ごとにファイルを分けたり、カスタムフックを分離することも可能
過去のコードでは、基本的には「Presentational Component と Container Component との分離」の節で紹介したコンポーネントをページごとに個別に Fetch したり、サーバーサイドにリクエストを送っていました。
// Container.tsx
import { Presentational, PresentationalProps } from "./Presentational";
const useGenerateProps = (): PresentationalProps => {
const getCroppedImage = () => {
// crop処理
};
const handleUploadImage = () => {
const croppedImage = getCroppedImage();
// uploadの処理
};
return {
userName: "keisuke taniai",
photoUrl: "https://hireroo.io/userImages/xxxx",
imageCropDialog: {
onClick: handleUploadImage,
},
};
};
export const Container = () => {
const props = useGenerateProps();
return <Presentational {...props} />;
};
2 種類の Container Component の振る舞い
各ページコンポーネントで共通で利用する CommonLayout というコンポーネントが存在します。
// EvaluationMetricGroup.tsx
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import * as React from "react";
import CommonLayout, {
CommonLayoutProps,
} from "../../layout/CommonLayout/CommonLayout";
import EditCard, { EditCardProps } from "./parts/EditCard/EditCard";
import MetricListCard, {
MetricListCardProps,
} from "./parts/MetricListCard/MetricListCard";
export type EvaluationMetricGroupProps = {
layout: CommonLayoutProps;
editCard: EditCardProps;
metricListCard: MetricListCardProps;
};
const EvaluationMetricGroup: React.FC<EvaluationMetricGroupProps> = (props) => {
return (
<CommonLayout
{...props.layout}
heading={{
text: "タイトル",
}}
>
<Box mt={4}>
<Grid container spacing={3}>
<Grid item xs={4}>
<EditCard {...props.editCard} />
</Grid>
<Grid item xs={8}>
<MetricListCard {...props.metricListCard} />
</Grid>
</Grid>
</Box>
</CommonLayout>
);
};
export default EvaluationMetricGroup;
この CommonLayout はそれぞれの位置にコンポーネント(ReactNode)を要求しています。
その場合、Container 層ではこの CommonLayout が要求する Props に整形しなくてはなりません。
例えば、フッターには必要なリンク情報のみを必要としているため、それぞれのリンクとリンクのテキストが、ヘッダーにはログイン中のアカウント情報を見せる必要があるため、ログイン中のユーザー情報を取得する必要があります。
これらを各 Container Component でそのたびに記述していると冗長となってしまいます。
// CommonLayout.tsx
export type CommonLayoutProps = {
Header: React.ReactNode;
Footer: React.ReactNode;
heading?: HeadingProps;
};
const CommonLayout: React.FC<React.PropsWithChildren<CommonLayoutProps>> = (
props
) => {
return (
<Box>
{props.Header}
<Container maxWidth="lg">
<Box height="100%" minHeight={window.innerHeight - Spaces}>
{props.heading && <Heading {...props.heading} />}
{props.children || (
<Box mt={2}>
<Stack spacing={2}>
<Skeleton
key="skelton-1"
height="200px"
variant="rectangular"
/>
<Skeleton
key="skelton-2"
height="200px"
variant="rectangular"
/>
</Stack>
</Box>
)}
</Box>
</Container>
<Box mt={2}>{props.Footer}</Box>
</Box>
);
};
export default CommonLayout;
そのため、独立してどこからでも参照されうる可能性のあるコンポーネントを Widget として切り出しており、その機能を必要としているコンポーネントに差し込むようにして利用できるようにしています。
Widget も同じファイル構成をしているため、差し込んで利用する際に内部で初期値が必要であれば、FetchContainer 経由でデータを取得し、useGenerateProps でデータを整形し、Container 経由で UI に Props を渡すという流れで独立した形で利用することができます。
Container 層のデータ取得から整形までを規格化することで、共通した処理を Widget 内部にまとめることができるのと、各 Page で利用する際は Widget を外側から差し込むことで利用できるといったメリットを享受することができるようになります。
return {
layout: {
Header: <EmployeeNavigationContainer />,
Footer: <FooterContainer />,
},
};
おわりに
本記事では、フロントエンドの UI とビジネスロジックの責務の分離についての方針をベースに新旧アーキテクチャでどのようなコードの変更があったのかというのをできるだけ具体的なコードを使って説明しました。
この新しいアーキテクチャに従うことで、
- Presentation 層**-> 再利用性の向上**
- Widget の切り出し**-> 再レンダリング抑制**-> ポータビリティ向上**-> 実装のパターン化**
- Container 層**-> 責務のレイヤー化**
といったメリットを享受できるようになりました。
その結果「コードの可読性」、「保守のしやすさ」といった開発速度を阻害していていた要素を改善することができました。
まだまだアーキテクチャの移行途中ではあるため、チーム全体に共有できていない状態ですが、リアーキテクチャチームのフロントエンドのレベルが上がったことを実感します。
実際にチーム全体に広げたときに、どのようなメリット・デメリットが生まれたかについては別途移行終了後に記事にする予定です。こちらもご期待ください!
明日は @iwata の「Backend For Frontend と React で GraphQL を導入するための構成」についての記事です。