【React デザインパターン】Compound Componentsを使って直感的なコンポーネントを実装しよう (TypeScript対応)

Posted: June 25, 2022

Compound Componentsとは

Compound Componentsは、親コンポーネントで全体を囲み、子コンポーネントをユーザーに指定させるパターンです。膨れ上がったデータをProps経由で渡すのではなく、子コンポーネントとして渡すようにします。
このようにすることで、親コンポーネントと子コンポーネント間の凝集度を高め、より直感的なインターフェースを提供することが可能です。
HTMLの例で考えましょう。

<select>
	<option value="react">React</option>
	<option value="vue">Vue</option>
	<option value="angular">Angular</option>
</select>

<select>はセレクトボックスの大枠を表し、<option>はその中で表示されるアイテムを表しています。
HTMLでは、<option><select>の子として渡すことができます。<select>要素は状態を管理し、子要素として渡された<option>がセレクトボックスのアイテムとして扱われていることが安易に理解できるかと思います。
Reactでも非常によく似たことができ、親コンポーネントが暗黙の状態(state)を管理し、子コンポーネントをその要素として渡せます。これがまさにCompound Componentsです。

実装例

今回は例として、Cardコンポーネントを実装してみましょう。CardコンポーネントはTitleBodyBody内にItemを配列として受け取り表示できると想定して実装します。

Propsを渡して実装するケース

まずCompound Componentパターンを使わず、Propsを渡していく方法で実装を考えていきます。
CardコンポーネントをPropsを通して受け取り、実装します。

import React, { useState } from "react";

interface CardItem {
  id: number;
  text: string;
}

interface CardProps {
  title: string;
  items: CardItem[];
}

export const Card: React.FC<CardProps> = ({ title, items }) => {
  const [isShow, setIsShow] = useState(false);
  const handleClick = () => setIsShow((show) => !show);
  return (
    <div>
      <h2>{title}</h2>
      <div>
        <button onClick={handleClick}>Show items</button>
        {isShow && (
          <div>
            {items.map((item) => (
              <div key={item.id}>{item.text}</div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
};

Propsを介してitems titleを受け取り、表示しています。またstateやロジックなども一体となったコンポーネントとなっていますね。

このCardコンポーネントを利用するコンポーネントを実装してみましょう。

import type { NextPage } from "next";
import { Card } from "../components/Card";

const Home: NextPage = () => {
  const cardItems = [
    { id: 1, text: "親子によるUIとロジックの分離" },
    { id: 2, text: "Propsではなく子コンポーネントとして渡す" },
    { id: 3, text: "コンポーネントの再利用性が上がる" },
  ];
  return (
    <div>
      <Card title="Props pattern" items={cardItems} />
    </div>
  );
};

export default Home;

コードの記述量は少ないですが、使う側ではいまいち直感的なインターフェースになっていないように感じます。またPropsを介して呼び出しているため、Propsに変更があればCardコンポーネント全体の再レンダリングが行われてしまいます。

Compound Componentパターンを利用するケース

次にCompound Componentパターンを利用するケースについて考えていきましょう。
同様にCardコンポーネントから考えましょう。

import { useState } from "react";

interface CardProps {
  children: React.ReactNode;
}

interface CardComposition {
  Title: React.FC<CardTitleProps>;
  Body: React.FC<CardBodyProps>;
  Item: React.FC<CardItemProps>;
}

interface CardTitleProps {
  children: React.ReactNode;
}

interface CardBodyProps {
  children: React.ReactNode;
}

interface CardItemProps {
  children: React.ReactNode;
}

export const Card: React.FC<CardProps> & CardComposition = ({ children }) => {
  return <div>{children}</div>;
};

const CardTitle: React.FC<CardTitleProps> = ({ children }) => {
  return <h2>{children}</h2>;
};

const CardBody: React.FC<CardBodyProps> = ({ children }) => {
  const [isShow, setIsShow] = useState(false);
  const handleClick = () => setIsShow((show) => !show);
  return (
    <div>
      <button onClick={handleClick}>Show items</button>
      {isShow && <div>{children}</div>}
    </div>
  );
};

const CardItem: React.FC<CardItemProps> = ({ children }) => {
  return <div>{children}</div>;
};

Card.Title = CardTitle;
Card.Body = CardBody;
Card.Item = CardItem;


型宣言のためコードが少し増えていますが、CardのTitle, Body, Itemと責務をそれぞれわけることができています。
またstateを保持するステートフルなコンポーネントとUIの表示のみのステートレスなコンポーネントに分割できてもいて、見通しがよくなっています。

次にCardコンポーネントを利用するコンポーネントを実装してみましょう。

import type { NextPage } from "next";
import { Card } from "./components/Card";

const Compound: React.FC = () => {
  const cardItems = [
    { id: 1, text: "親子によるUIとロジックの分離" },
    { id: 2, text: "Propsではなく子コンポーネントとして渡す" },
    { id: 3, text: "コンポーネントの再利用性が上がる" },
  ];
  return (
    <div>
      <Card>
        <Card.Title>Component compound pattern</Card.Title>
        <Card.Body>
          {cardItems.map((cardItem) => (
            <Card.Item key={cardItem.id}>{cardItem.text}</Card.Item>
          ))}
        </Card.Body>
      </Card>
    </div>
  );
};


Propsを渡す場合との大きな違いとして、

  • 子コンポーネントとして渡すため、再レンダリング時のコストが下がる
  • 使う側のコンポーネントの柔軟性が上がる
  • より直感的なインターフェースである

などが挙げられます。

まとめ

Compound Componentは、データをProps経由で渡すのではなく、子コンポーネントとして渡す親子関係で構築されるコンポーネントで非常に有効なデザインパターンです。

親コンポーネントと子コンポーネント間の凝集度を高め、より直感的なインターフェースを提供でき、柔軟性やパフォーマンスの向上も期待できます。是非使えるところは積極的に採用していきましょう。