🚀

UIコンポーネント作成でのアンチパターン

2024/06/20に公開

新しい現場で色々なアンチパターンを見てきましたが、この記事ではUIコンポーネントのアンチパターンに関して扱います。

扱う対象

  • ButtonやTextInputなどのAtom/Moleculeレベルのコンポーネント
  • Reactのサンプルコードになりますが、他のライブラリでも基本は同じと思います

アンチパターンのコンポーネント例

実際にリファクタリングしたコンポーネントのコードです。
セクションを分かりやすくしたカードコンポーネントで、テーブルや何らかの情報をグループ化するときに活用されるものです。

Card.tsx
import { fcBlue, primary } from "@/styles/color";
import styled from "@emotion/styled";
import { Card as ACard } from "antd";
import { FC, ReactNode } from "react";

export type Props = {
  type?: "primary" | "default";
  style?: React.CSSProperties;
  children?: ReactNode;
  title?: string | ReactNode;
};

export const Card: FC<Props> = (props: Props) => {
  const border = props.type === "primary" ? `4px solid ${primary}` : "0px";
  return (
    <ACard
      style={{
        border,
        borderRadius: "24px",
        padding: "48px",
        textAlign: "center",
        ...props.style,
      }}
    >
      {props.title && <Title>{props.title}</Title>}
      {props.children}
    </ACard>
  );
};

const Title = styled.div`
  font-weight: bold;
  border-left: 8px solid ${fcBlue};
  text-align: left;
  font-size: 24px;
  padding-left: 12px;
`;

何が問題なのか

propsを見ただけで、多くの問題が分かります。

別物のコンポーネントを1つのコンポーネントに収めようとしている

propsのtypeでスタイルを分岐させています。
コードだけでは判断できないと思いますが、primaryとdefaultで全く異なる使われ方をしています。
結果、使い手にとって分かりづらい&不便なコンポーネントになっていました。

  • textAlign: "center"はprimary用なので、defaultを使う場合は、呼び出し側でtextAlign: leftを指定することになる
  • titleはdefault用になっていて、primaryを活用する場合は呼び出し側でTitleを設定している
    ※そもそも、primaryとdefaultの名称では違いを区別できない、という突っ込みもあります

styleを外から渡せる

root部分のstyleを外から渡せるようになっていますが、これを許可すると呼び出し側で全く異なるコンポーネントとして活用される可能性が生まれます。
コンポーネント化の目的の一つに統一性、一貫性を持たせることもあると考えられるので、便利かもしれませんがstyleを直接上書きすることはNGと考えています。
(color propsを持たして呼び出し元で色を指定できるなどはOK)

titleの持ち方

何が問題なのか、呼び出し側のコードから解説します

    <Card title={title}>
      <div style={{ textAlign: "left", margin: "16px 0 40px 0" }}>
        ・・・・
      </div>
    </Card>

まず、可読性の観点からpropsとして渡す形になると、構造が分かりづらいという問題があります。

また、柔軟なスタイリングができません。
要素感のスペースはStack(Flex)コンポーネントのgapで表現することを基本としていますが、titleがCardに隠蔽されていることで、titleにStackを適用することができません。
タイトルの横に何か要素を並べたいときは、titleにJSXをぶち込む形で実現していました。

    <Card
      title={
        <Flex justify="space-between">
            <p>返済スケジュール</p>
            <DownloadButton onClick={() => onDownload(monthlyStatementID)}>
            PDFをダウンロード
            </DownloadButton>
        </Flex>
      }
    >

titleにボタンを渡すのは意味が分からないですし、可読性も悪いですよね。
型がstring | ReactNodeとなっているのも(ReactNodeにstring含まれている突っ込みもありますが)、基本はstringに制限するものと思います。

不必要にコンポーネントライブラリを活用する

antdのCardをラップする形で作成されてますが、白枠を提供するだけのシンプルなコンポーネントなので活用するメリットはありませんでした、
一方、実害として想定しているpadding以上の値が適用されている(antd側が持っているpaddingの影響)という問題が発生していました。
ライブラリのアップデートでスタイルが崩れるなど、将来的なリスクも抱えることになるので、活用したい機能がない場合は無理にコンポーネントライブラリを活用することはないと思います。

余談ですが、コンポーネントライブラリの活用自体は(大半の現場で)mustと考えていますが、antdは使い勝手が悪く苦しんでいます・・・

改善したコンポーネント

以下のように改善しました。

Card.tsx
import { ElementType, FC, ReactNode } from "react";
import classes from "./Card.module.scss";
import { Title } from "@/components/typography/Title/Title";

export type Props = {
  as?: ElementType;
  children: ReactNode;
};

export const Card: FC<Props> & { Title: FC<{ children: string }> } = ({
  as: Tag = "section",
  children,
}) => {
  return <Tag className={classes.card}>{children}</Tag>;
};

const CardTitle: FC<{ children: string }> = ({ children }) => (
  <div className={classes.title}>
    <Title order={2}>{children}</Title>
  </div>
);

Card.Title = CardTitle;
style
Card.module.scss
@use "@/styles/variables" as *;

.card {
  background-color: $neutral-0;
  border-radius: $radius_xl;
  padding: $spacing_2xl;
}

.title {
  border-left: 8px solid $primary-7;
  padding-left: 12px;
}

呼び出し側の例としては以下です。

<Card>
  <Stack gap="lg" align="stretch">
    <Flex justify="space-between">
        <Card.Title>返済スケジュール</Card.Title>
        <DownloadButton onClick={() => onDownload(monthlyStatementID)}>
          PDFをダウンロード
        </DownloadButton>
    </Flex>
 ・・・・
</Card>

改善したポイント

  • type propsは除き、シンプルな形に変更
  • style propsは除き、自由にスタイルを変えられるリスクを排除
  • title propsは除き、<Card.Title>でタイトルを指定することで、呼び出し元のJSXに含められる形に変更
  • デフォルトのタグをsectionにして、変えられるようにas propsを生やした
  • RSCを見据え、emotion→CSS Modulesに変更
  • titleは別途作成したTitleコンポーネントを活用する形に変更

まとめ 〜良いUIコンポーネントとは〜

いかがでしたでしょうか?
元のコンポーネントと比べ、シンプルにかつリスクを排除した形にできたと考えています。

アンチパターンを見ることで、私も改めて良いUIコンポーネントに関して考えるようになりました。
今回出てきてない話もありますが、良いUIコンポーネントを作成するためのポイントをあげてみます。

  • 役割が明確(1つ)になっていること
    • 継ぎ足しでpropsをやたらと増やしたりしない
  • 呼び出し元の構造やスタイリングに影響を与えない
    • 今回のtitle propsやUIコンポーネントにmarginを持たせないなど
  • 呼び出し元に自由を与えすぎない
  • コンポーネントライブラリはラップして活用(機能側で直接呼び出さない)する
    • そもそも使うメリットがないものに関しては活用しない
  • ドメイン知識は持たせない
    • 汎用的に使える形にする

まだまだ手探りしながら開発している状況なので、こういったことも意識すると良くなるなど、何かコメントいただけますと幸いです。

Discussion