合成という考え方

Part 2 · コンポーネントと props

合成という考え方

継承ではなく組み合わせで作る理由

コンポーネントを作っていくと、必ずこの分かれ道に来ます。

似ているけれど少し違うものを、どう表現するか。

多くの言語では、継承して差分だけ書き換えます。 React ではそれをしません。組み合わせて作ります。これを合成と呼びます。

props で全部を受け取ろうとすると

カードを作るとします。最初はタイトルと本文だけでした。 そこへ「バッジも出したい」「下に更新日も出したい」と要望が来ます。

props で受け取る作りだと、こうなっていきます。

props で中身を受け取るカードうまくいかない例

表示できるものが増えるたびに props が増える

メンバー3 名

表示したいものが増えるたびに、props も増えていく

最終更新: 今日

問題は 2 つあります。

  • props が際限なく増えるshowBadgebadgeLabel のように、 出すかどうかと中身がセットで増えていきます
  • 決めた形しか出せない … 「バッジの代わりにボタンを置きたい」と言われたら、 また新しい props が必要です

そして Card は、あらゆる使われ方を知っている巨大な部品になっていきます。使う人が増えるほど、部品が複雑になる作りです。

props-explosion.tsx

// 中身のパターンを props で受け取ろうとすると、こうなっていく
function Card({
  title,
  description,
  badgeLabel,
  showBadge,
  showFooter,
  footerText,
}: {
  title: string;
  description: string;
  badgeLabel?: string;
  showBadge?: boolean;
  showFooter?: boolean;
  footerText?: string;
}) {
  return (
    <div className="rounded-md border">
      <div className="flex items-center justify-between gap-3 border-b p-3">
        <span className="font-semibold">{title}</span>
        {showBadge && (
          <span className="rounded border px-2 py-0.5 text-xs">
            {badgeLabel}
          </span>
        )}
      </div>
      <p className="p-3 text-muted-foreground">{description}</p>
      {showFooter && (
        <p className="border-t p-3 text-muted-foreground">{footerText}</p>
      )}
    </div>
  );
}

export function PropsExplosion() {
  return (
    <Card
      title="メンバー"
      description="表示したいものが増えるたびに、props も増えていく"
      showBadge
      badgeLabel="3 名"
      showFooter
      footerText="最終更新: 今日"
    />
  );
}

組み合わせて作る

合成では、逆の発想をします。枠は場所だけを決めて、何を置くかは呼ぶ側が決める。

組み合わせて作ったカード直した例

見た目は同じだが、Card は中身を知らない

メンバー3 名
必要なものだけを、必要な場所に置く
最終更新: 今日

CardCardHeaderCardBodyCardFooter。 どれも children を置くだけの、数行の部品です。showBadge のような props はひとつもありません。

それでいて、できることは増えています。 バッジの代わりにボタンを置きたければ、呼ぶ側でそう書けばいいだけです。Card を直す必要はありません。

composed.tsx

import type { ReactNode } from "react";

// 枠は「並べる場所」だけを決める。中身が何かは知らない
function Card({ children }: { children: ReactNode }) {
  return <div className="rounded-md border">{children}</div>;
}

function CardHeader({ children }: { children: ReactNode }) {
  return (
    <div className="flex items-center justify-between gap-3 border-b p-3">
      {children}
    </div>
  );
}

function CardBody({ children }: { children: ReactNode }) {
  return <div className="p-3 text-muted-foreground">{children}</div>;
}

function CardFooter({ children }: { children: ReactNode }) {
  return <div className="border-t p-3 text-muted-foreground">{children}</div>;
}

export function Composed() {
  return (
    <Card>
      <CardHeader>
        <span className="font-semibold">メンバー</span>
        <span className="rounded border px-2 py-0.5 text-xs">3 名</span>
      </CardHeader>

      <CardBody>必要なものだけを、必要な場所に置く</CardBody>

      <CardFooter>最終更新: 今日</CardFooter>
    </Card>
  );
}

特別なものは、包んで作る

「よく使う組み合わせ」が決まっているなら、 それを包んだ部品を作ります。継承ではなく、汎用のものを使う専用のものを作るわけです。

// 汎用の Alert を使って、専用の ErrorAlert を作る
function ErrorAlert({ children }: { children: ReactNode }) {
  return <Alert tone="error" icon={<XIcon />}>{children}</Alert>;
}

ErrorAlertAlert を継承しているのではなく、使っているだけです。 呼ぶ側は tone を指定しなくてよくなり、Alert 自体は何も変わりません。

なぜ継承を使わないのか

継承だと、親を直したときにそれを継いだ全部に影響します。 しかも「どこまでが親の機能か」を知らないと、子を読めません。

合成なら、使っているものは書いてあるとおりです。ErrorAlert のコードを見れば、Alert を使っていることが 1 行で分かります。隠れた関係が生まれません。

composed.tsx

import type { ReactNode } from "react";

// 枠は「並べる場所」だけを決める。中身が何かは知らない
function Card({ children }: { children: ReactNode }) {
  return <div className="rounded-md border">{children}</div>;
}

function CardHeader({ children }: { children: ReactNode }) {
  return (
    <div className="flex items-center justify-between gap-3 border-b p-3">
      {children}
    </div>
  );
}

function CardBody({ children }: { children: ReactNode }) {
  return <div className="p-3 text-muted-foreground">{children}</div>;
}

function CardFooter({ children }: { children: ReactNode }) {
  return <div className="border-t p-3 text-muted-foreground">{children}</div>;
}

export function Composed() {
  return (
    <Card>
      <CardHeader>
        <span className="font-semibold">メンバー</span>
        <span className="rounded border px-2 py-0.5 text-xs">3 名</span>
      </CardHeader>

      <CardBody>必要なものだけを、必要な場所に置く</CardBody>

      <CardFooter>最終更新: 今日</CardFooter>
    </Card>
  );
}

どこで切り替えるか

最初から合成で作る必要はありません。 props が 2 つ 3 つのうちは、そのほうが簡単です。

次のような兆候が出たら、合成に切り替えどきです。

  • showXxx のような、出すかどうかを決める props が増えてきた
  • props の数が増え、どれとどれを一緒に使うのか分かりにくい
  • 「この場合だけ、ここに別のものを置きたい」という要望が繰り返し来る

props-explosion.tsx

// 中身のパターンを props で受け取ろうとすると、こうなっていく
function Card({
  title,
  description,
  badgeLabel,
  showBadge,
  showFooter,
  footerText,
}: {
  title: string;
  description: string;
  badgeLabel?: string;
  showBadge?: boolean;
  showFooter?: boolean;
  footerText?: string;
}) {
  return (
    <div className="rounded-md border">
      <div className="flex items-center justify-between gap-3 border-b p-3">
        <span className="font-semibold">{title}</span>
        {showBadge && (
          <span className="rounded border px-2 py-0.5 text-xs">
            {badgeLabel}
          </span>
        )}
      </div>
      <p className="p-3 text-muted-foreground">{description}</p>
      {showFooter && (
        <p className="border-t p-3 text-muted-foreground">{footerText}</p>
      )}
    </div>
  );
}

export function PropsExplosion() {
  return (
    <Card
      title="メンバー"
      description="表示したいものが増えるたびに、props も増えていく"
      showBadge
      badgeLabel="3 名"
      showFooter
      footerText="最終更新: 今日"
    />
  );
}

理解できたか確かめる

確認クイズ

showBadge / badgeLabel のような props が増えてきた。どうする?

確認クイズ

React で継承を使わないのはなぜ?

composed.tsx

import type { ReactNode } from "react";

// 枠は「並べる場所」だけを決める。中身が何かは知らない
function Card({ children }: { children: ReactNode }) {
  return <div className="rounded-md border">{children}</div>;
}

function CardHeader({ children }: { children: ReactNode }) {
  return (
    <div className="flex items-center justify-between gap-3 border-b p-3">
      {children}
    </div>
  );
}

function CardBody({ children }: { children: ReactNode }) {
  return <div className="p-3 text-muted-foreground">{children}</div>;
}

function CardFooter({ children }: { children: ReactNode }) {
  return <div className="border-t p-3 text-muted-foreground">{children}</div>;
}

export function Composed() {
  return (
    <Card>
      <CardHeader>
        <span className="font-semibold">メンバー</span>
        <span className="rounded border px-2 py-0.5 text-xs">3 名</span>
      </CardHeader>

      <CardBody>必要なものだけを、必要な場所に置く</CardBody>

      <CardFooter>最終更新: 今日</CardFooter>
    </Card>
  );
}

この章のまとめ

  • React では継承せず、組み合わせて作る
  • props で中身のパターンを受け取ると、使い道が増えるほど部品が複雑になる
  • children で差し込めるようにすると、 枠は中身を知らなくて済み、変更が呼ぶ側で完結する
  • 特別なものは、汎用のものを包んで作る
  • showXxx のような props が増えてきたら切り替えどき

composed.tsx

import type { ReactNode } from "react";

// 枠は「並べる場所」だけを決める。中身が何かは知らない
function Card({ children }: { children: ReactNode }) {
  return <div className="rounded-md border">{children}</div>;
}

function CardHeader({ children }: { children: ReactNode }) {
  return (
    <div className="flex items-center justify-between gap-3 border-b p-3">
      {children}
    </div>
  );
}

function CardBody({ children }: { children: ReactNode }) {
  return <div className="p-3 text-muted-foreground">{children}</div>;
}

function CardFooter({ children }: { children: ReactNode }) {
  return <div className="border-t p-3 text-muted-foreground">{children}</div>;
}

export function Composed() {
  return (
    <Card>
      <CardHeader>
        <span className="font-semibold">メンバー</span>
        <span className="rounded border px-2 py-0.5 text-xs">3 名</span>
      </CardHeader>

      <CardBody>必要なものだけを、必要な場所に置く</CardBody>

      <CardFooter>最終更新: 今日</CardFooter>
    </Card>
  );
}