再利用可能なコンポーネント vs 複合可能なコンポーネント
Reactでコンポーネントを実装する際に、再利用しやすいように作ると、良いコンポーネントに見えるし、使いやすくなります。
しかし、「再利用性」を意識しすぎてコンポーネントを実装した結果、以下のようにpropsが多くなってしまった経験はないでしょうか?
<MyCard
profileImage={....}
profileShape="circle"
link="https://www......"
title="カード"
description="カードに関する説明"
size="lg"
color="skyblue"
showSnsButton={true}
showFooter={false}
layout="vertical"
/>
このようなコンポーネントは、最初のうちは手軽に実装できますが、仕様の追加や変更が重なるにつれて、徐々にメンテナンスしづらくなっていきます。
「再利用性」より「複合性」に目線を向けてみたらどうでしょうか。
実装のイメージは以下の通り
<MyCard>
<MyCard.Profile img={...} />
<MyCard.Body />
<MyCard.Footer />
</MyCard>
こういう書き方をCompound Componentsパターンと言います。
Compound Componentsパターンとは
Compound Componentsパターンとは、複数のコンポーネントを組み合わせて一つの機能として動作させるReactのパターンです。
メリット
1. 関連するコンポーネントをまとめられる
MyCard.HeaderやMyCard.Bodyのように書けるため、そのコンポーネントが何に属しているのかが分かりやすくなります。
2. 拡張しやすい
例えば、<MyCard>コンポーネントにDeleteボタンを追加する仕様が追加されても拡張しやすいです。
<MyCard>
<MyCard.Profile img={...} />
<MyCard.Body />
<MyCard.Footer />
<MyCard.DeleteButton />
</MyCard>
3. Contextで状態の共有ができる
Tabs機能を実装することを仮定しましょう。まず、Tabsコンポーネントの仕様は以下のとおりです。
1. 複数のTabボタンが存在する。
2. Tabボタンをクリックすると、該当のTabが有効化され、対応する内容が表示される。
この場合、Tabボタンが有効化なのか状態を管理する必要があります。通常であれば、この状態は親で管理してpropsとして各子に渡す必要がありますが、子の数が増えるほどprops経由のやり取りが煩雑になります。(props drilling)
しかし、Tabsコンポーネントの中でContextを使えば、階層を気にせずどこからでも状態にアクセスできるため、楽に状態管理ができます。
Compound Componentsバターンが向いているケース
- 複数の部品で構成される UI
- 親と子が状態を共有する UI
- Tabs、Accordion、Modal、Card など
例1)カードコンポーネント(状態管理なしのパターン)
このようなユーザーカードを実装してみましょう。
<UserCard
profileImg={画像ファイル}
title="ワンちゃん"
description="こんにちは。お肉が大好きです"
snsLists={[
{snsName:'instagram', snsLink: "https://insta....."},
{snsName: 'x', snsLink: "https://x....."}
]}
/>
いい感じのコンポーネントになりました。(?)
しかし、追加要望が来てしまいました。
こんな場合にも既存のコンポーネントにPropsを追加したら対応はできますが、複雑になる可能性が高いです。
ここで、先ほどご紹介した Compound Componentsパターンが活躍します。
コードは以下の通りです。
UserCardコンポーネントの使用例
<UserCard>
<UserCard.ProfileImg imgSrc={dogImg} />
<UserCard.Body>
<UserCard.Title>ワンちゃん</UserCard.Title>
<UserCard.Description>
こんにちは。お肉が大好きです
</UserCard.Description>
<UserCard.SnsBadgeContainer>
<UserCard.SnsBadge snsName="instagram" snsLink="#" />
<UserCard.SnsBadge snsName="x" snsLink="#" />
</UserCard.SnsBadgeContainer>
</UserCard.Body>
</UserCard>
UserCard.tsxの中身
const snsIconMap = {
x: xIcon,
instagram: instagramIcon,
facebook: facebookIcon,
} as const;
type SnsType = keyof typeof snsIconMap;
// UserCard Rootコンポーネント
type UserCardRootProps = {
children: ReactNode;
};
const UserCardRoot = ({ children }: UserCardRootProps) => {
return <article className="card-root">{children}</article>;
};
// UserCardのProfileコンポーネント
type UserCardProfileImgProps = {
imgSrc: string;
alt: string;
};
const UserCardProfileImg = ({ imgSrc, alt }: UserCardProfileImgProps) => {
return (
<div className="profile">
<img src={imgSrc} alt={alt} className="profile__img" />
</div>
);
};
// UserCardのBodyコンポーネント
type UserCardBodyProps = {
children: ReactNode;
};
const UserCardBody = ({ children }: UserCardBodyProps) => {
return <div className="card-body">{children}</div>;
};
// UserCardのTitleコンポーネント
type UserCardTitleProps = {
children: ReactNode;
};
const UserCardTitle = ({ children }: UserCardTitleProps) => {
return <div className="title">{children}</div>;
};
// UserCardのDescriptionコンポーネント
type UserCardDescriptionProps = {
children: ReactNode;
};
const UserCardDescription = ({ children }: UserCardDescriptionProps) => {
return <div className="description">{children}</div>;
};
// UserCardのBadgeContainerコンポーネント
type UserCardSnsBadgeContainerProps = {
children: ReactNode;
};
const UserCardSnsBadgeContainer = ({
children,
}: UserCardSnsBadgeContainerProps) => {
return <div className="sns-badge-container">{children}</div>;
};
// UserCardのSnsBadgeコンポーネント
type UserCardSnsBadgeProps = {
snsName: SnsType;
snsLink: string;
};
const UserCardSnsBadgeProps = ({ snsName, snsLink }: UserCardSnsBadgeProps) => {
const iconMap: Record<SnsType, string> = {
x: xIcon,
instagram: instagramIcon,
facebook: facebookIcon,
};
return (
<a
href={snsLink}
className="sns-badge"
target="_blank"
rel="noopener noreferrer"
>
<img src={iconMap[snsName]} alt={`${snsName} icon`} />
</a>
);
};
// UserCardのMoreButtonコンポーネント
const UserCardMoreButton = () => {
return <button className="more-button"></button>;
};
// UserCardのTimeコンポーネント
type UserCardTimeProps = {
timeString: string;
};
const UserCardTime = ({ timeString }: UserCardTimeProps) => {
return <time className="time">{timeString}</time>;
};
const UserCard = Object.assign(UserCardRoot, {
ProfileImg: UserCardProfileImg,
Body: UserCardBody,
Title: UserCardTitle,
Description: UserCardDescription,
SnsBadgeContainer: UserCardSnsBadgeContainer,
SnsBadge: UserCardSnsBadgeProps,
MoreButton: UserCardMoreButton,
Time: UserCardTime,
});
export default UserCard;
UserCard.tsxには複数のコンポーネントを定義しており、Object.assignを使ってそれらをまとめて exportしています。
これは、各コンポーネントが UserCardに属するコンポーネントであることを明確にするためです。
例2)Tabsコンポーネント(状態管理ありのパターン)
useContext と useState を使って状態を共有する Compound Components を実装することもできます。
代表的な例として、以下のような Tabs コンポーネントも実装できます。
使用例
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">プロフィール</Tabs.Trigger>
<Tabs.Trigger value="donate">寄付</Tabs.Trigger>
<Tabs.Trigger value="form">フォーム</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="profile">
<p>プロフィールページの内容が入ります。</p>
</Tabs.Content>
<Tabs.Content value="donate">
<p>お肉を食べるために寄付をお待ちしております。</p>
<div className="img-center">
<img src={dogMeat} alt="img" />
</div>
</Tabs.Content>
<Tabs.Content value="form">
<form action="/">
<label htmlFor="name">金額</label>
<input className="name-input" id="name" name="name" type="number" />
<button className="submit-button" type="submit">
寄付する
</button>
</form>
</Tabs.Content>
</Tabs>
Tabs.tsxの中身
import { createContext, useContext, useState, type ReactNode } from 'react';
type TabsContextType = {
activeValue: string;
setActiveValue: (value: string) => void;
};
const TabsContext = createContext<TabsContextType | null>(null);
const useTabsContext = () => {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs コンポーネントは <Tabs> の内部で使用する必要があります。');
}
return context;
};
type TabsRootProps = {
defaultValue: string;
children: ReactNode;
};
const TabsRoot = ({ defaultValue, children }: TabsRootProps) => {
const [activeValue, setActiveValue] = useState(defaultValue);
return (
<TabsContext.Provider value={{ activeValue, setActiveValue }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
};
type TabsListProps = {
children: ReactNode;
};
const TabsList = ({ children }: TabsListProps) => {
return <div role="tabs__list">{children}</div>;
};
type TabsTrigger = {
value: string;
children: ReactNode;
};
const TabsTrigger = ({ value, children }: TabsTrigger) => {
const { activeValue, setActiveValue } = useTabsContext();
const isActive = activeValue === value;
return (
<button
type="button"
role="tab"
aria-selected={isActive}
className="tabs__trigger"
data-active={isActive}
onClick={() => setActiveValue(value)}
>
{children}
</button>
);
};
type TabsContentProps = {
value: string;
children: ReactNode;
};
const TabsContent = ({ value, children }: TabsContentProps) => {
const { activeValue } = useTabsContext();
if (activeValue !== value) return null;
return (
<div className="tabs__content" role="tabpanel">
{children}
</div>
);
};
const Tabs = Object.assign(TabsRoot, {
List: TabsList,
Trigger: TabsTrigger,
Content: TabsContent,
});
export default Tabs;
-
TabsRoot-
activeValueをuseStateで管理する -
TabsContext.Providerを通して、現在選択されているタブと更新関数を子コンポーネントに共有する
-
-
TabsList-
Tabs.Triggerをまとめるためのコンテナである
-
-
TabsTrigger-
useTabsContextからactiveValueとsetActiveValueを取得する - クリックされたときに、自身の value を現在のアクティブなタブとして設定する
-
-
TabsContent- 自身の value と
activeValueを比較する - 値が一致する場合のみ、対応するコンテンツを表示する
- 自身の value と
その他
以下のURLに接続したら、全体的なソースコードの確認ができます。
https://stackblitz.com/edit/vitejs-vite-dvvtvs7z?file=src%2Fcomponents%2FTabs.tsx




