🔖

「4種類」で管理するReactコンポーネント

2023/04/04に公開

はじめに

フロントエンドのコンポーネント実装ではレイヤーの分割方法が問題になります。無造作では散らかりますが、あまりに細かくても再利用性が失われて開発効率が落ちます。

ここでは、個人的に肌感覚で「このように分けるのが丁度良いな」という思考を整理してみました。

補足

  • Atomic Designは意識していませんが、噛み合う部分があるかもしれません。
  • 以下に述べるものは「理想」であり、すべて厳密に守って作業しないこともあります。

備考

  • ❗️マークは、人により好みが別れそうだけど個人的には推奨したい!という部分です。

A: 小さいコンポーネント

最小限の部品を構成するパーツで、最も抽象的です。

例: Button, Input, Link, Radio, Text, Heading, Avatar, Badge, Table

設計・実装

  • 最もHTMLに近い存在であり、DOMの隠蔽はほとんどしないレイヤーです。
  • ❗️ HTML要素が持つ属性の大半を受け取ります。classNameもマージします。
    • 特定のユースケースに縛られたり、E2Eテスト用に属性を差し込めなかったりする手間があってはいけません。
  • ❗️ propsには必須のキーがないことが望ましいです。(例外はLink系)
  • 可能な限りforwardRefすることを推奨します。

サンプルコードのイメージ

type ButtonProps = React.ComponentPropsWithRef<"button"> & {
  /**
   * @default "natural"
   */
  variant?: "primary" | "secondary" | "natural";
};

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (props, ref) => {
    const { variant = "natural", className, ...restProps } = props;

    return (
      <button
        className={clsx(styles.button, className)}
        data-variant={variant}
        {...restProps}
        ref={ref}
      />
    );
  }
);

Button.displayName = "Button";

export default Button;

B: 小〜中程度のコンポーネント

既存のHTMLよりも少し大きめの部品として使うUIです。大抵は(A)を組み合わせます。

例: Card, UserInfo, List, Pagination, Tabs, Accordion, Status

設計・実装

  • ❗️ 可能な限りHTML構造が見えていることが望ましいですが、必須ではありません。
  • ❗️ 場合に応じてHTML要素の属性を受け継がせるのが望ましいです。また汎用的に使えそうなものはcompound componentsにする選択肢もあります。
  • 場合に応じてロジックだけを切り出したunstyledなコンポーネントにします。
  • ❗️ propsには必須のキーがないことが望ましく、絶対にないと破綻するものだけを必須にします。

サンプルコードのイメージ

type CardProps = Omit<React.CompoenntPropsWithRef<"div">, "className"> & {
  rootClassName?: string;
  heading?: React.ReactNode;
  imgSrc?: string | undefined;
  imgAlt?: string | undefined;
  content?: React.ReactNode;
  buttonText?: React.ReactNode;
  onButtonClick?: React.ComponentProps<"button">["onClick"];
};

function Card(props: CardProps) {
  const {
    rootClassName,
    heading,
    imgSrc,
    imgAlt,
    content,
    buttonText,
    onButtonClick,
    ...rootProps
  } = props;

  return (
    <div className={clsx(styles.root, rootClassName)} {...rootProps}>
      <img src={imgSrc} alt={imgAlt} />
      <Heading>{heading}</Heading>
      <p>{content}</p>
      <Button onClick={onButtonClick}>{buttonText}</Button>
    </div>
  );
}

export default Card;

C: 中〜大程度のコンポーネント

ページ全体を覆うほどではないサイズですが、特定のデータモデルと結合したコンポーネントです。

例: UserCard, UserInfo, UserTable, UserList, HTTPErrorMessage

設計・実装

  • ビジネスロジックと結合しています。可能な限りHTML構造が見えていることが望ましいですが、必須ではありません。
  • HTML要素の属性は受け継がせなくてOKです。
  • propsには高確率でデータモデルの型がそのまま利用されます。その特性上、必須項目が多めです。
  • (A)や(B)を利用して実装されていることが望ましいですが、そうでない場合も有り得ます。
  • 初めは(B)で実装し、このデータに基づくユースケースしかない/書くのが面倒...という場合にこちらに行き着くことが多い印象です。

サンプルコードのイメージ

type UserTableProps = {
  users: User[];
};

function UserTable(props: UserTableProps) {
  const { users } = props;

  return (
    <Table.Wrapper>
      <Table>
        <Table.Thead>
          <Table.Tr>
            <Table.Th>ID</Table.Th>
            <Table.Th>Name</Table.Th>
            <Table.Th>Privilege</Table.Th>
          </Table.Tr>
        </Table.Thead>
        <Table.Tbody>
          {users.map((user) => (
            <Table.Tr>
              <Table.Td>{user.id}</Table.Td>
              <Table.Td>{user.name}</Table.Td>
              <Table.Td
                variant={user.privilege === "manager" ? "primary" : "natural"}
              >
                {user.privilege}
              </Table.Td>
            </Table.Tr>
          ))}
        </Table.Tbody>
      </Table>
    </Table.Wrapper>
  );
}

export default UserTable;

D: 大きなコンポーネント

複数の塊が結合してできたサイズで、要件の1機能と対になるような粒度です。大まかに「○○画面」と呼ばれることもあるでしょう。

設計・実装

  • 最もビジネスロジックと結合しています。HTML構造は見えません。
  • propsは持たないことが多いです。
  • APIからデータ取得し、下位のコンポーネントに値を接続します。

例: UserDashboard, UserTableScreen, SalesAnalysis, InvoiceForm

サンプルコードのイメージ

function UserDashboard() {
  const params = useParams();
  // fetch data from remote api
  const { data: user } = useUser(params.id);

  return (
    <div>
      <h1>{user.name}</h1>
      <Stat amount={user.score} />
      <UserCard user={user} />
    </div>
  );
}

export default UserDashboard;

まとめ

これまでを振り返ると次のようにまとめられます。

(A)小 (B)小〜中 (C)中〜大 (D)大
抽象度 最高 最低
UI 持つ 持つ どちらでも 持たない
業務ロジック 持たない 持たない 持つ 持つ
props 任意 任意が多い 必須が多い ほぼない
HTML 属性 指定可能 どちらでも ほぼない ない

この記事で述べたケースに当てはまらない場合もありますが、大分部はこの4つに分類されると思えば難なくコーディングできると思います。

ディレクトリ分割に対しては、(A)はデザインシステムとして必ず専用のディレクトリを切る必要を感じます。一方、(B)(C)(D)は場合により適当であることも多いです。(それがメリットにもなります)

【追記】 例外

今ではほとんど見られなくなったタイプですが、例外タイプとしてもう一つあります。それは、「副作用のみを担うコンポーネント」になります。

このHeadTitleコンポーネントは、コンポーネントのライフサイクルを用いてJSXの描画に関わらない副作用だけ処理が書いてあります。呼び出すことでそれを実行しますが、Viewには何も関わりがありません。

type HeadTitleProps = {
  children: string;
};

function HeadTitle({ children }: HeadTitleProps) {
  React.useEffect(() => {
    document.title = children;
  }, [children]);

  return null;
}

export function App() {
  return (
    <>
      <HeadTitle>Custom title</HeadTitle>
      <div>
        <h1>Demo page</h1>
      </div>
    </>
  );
}

現代ではHooksにすればよいので、敢えて新規実装する意味はないでしょう。

function useHeadTitle(title: string) {
  React.useEffect(() => {
    document.title = title;
  }, [title]);
}

export function App() {
  useHeadTitle('Custom title');

  return (
    <div>
      <h1>Demo page</h1>
    </div>
  );
}

Discussion