UIコンポーネント作成でのアンチパターン
新しい現場で色々なアンチパターンを見てきましたが、この記事ではUIコンポーネントのアンチパターンに関して扱います。
扱う対象
- ButtonやTextInputなどのAtom/Moleculeレベルのコンポーネント
- Reactのサンプルコードになりますが、他のライブラリでも基本は同じと思います
アンチパターンのコンポーネント例
実際にリファクタリングしたコンポーネントのコードです。
セクションを分かりやすくしたカードコンポーネントで、テーブルや何らかの情報をグループ化するときに活用されるものです。
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は使い勝手が悪く苦しんでいます・・・
改善したコンポーネント
以下のように改善しました。
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
@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