同一のシステムで以下の3つの画面があったとき、
「フッター部分は共通コンポーネントとして実装しよう」と思っていないだろうか?
「デザイン一緒だし、全部確定系の処理だからDRY原則に従って共通化しよう」と思った人は
この記事をぜひ読んでほしい。
この考え方の問題点
この考え方の問題点は4つ存在する。
①コンポーネント自体が複雑化する
新しい要件により内部の条件分岐が増え、かえって複雑度が増す
■初期リリース時
シンプルなボタンであり、問題は無さそうに見える。
type ButtonProps = {
label: string;
onClick: () => void;
};
export function Button({ label, onClick }: ButtonProps) {
return (
<button className="btn-primary" onClick={onClick}>
{label}
</button>
);
} ■要件追加を繰り返した結果
- 予約フォームで使う「フォーム送信」ボタンと、アカウント登録ページで使う「アカウン登録」ボタンの機能や見た目の差分をコンポーネント側で吸収することになる
- テストする際の組み合わせもそれ相応に大きくなる
type ButtonProps = {
label: string;
variant?: 'primary' | 'secondary' | 'tertiary' | 'danger' | 'ghost';
size?: 'small' | 'medium' | 'large';
isDisabled?: boolean;
isLoading?: boolean;
showBackIcon?: boolean;
showForwardIcon?: boolean;
showDownloadIcon?: boolean;
showUploadIcon?: boolean;
showMenuIcon?: boolean; // ← 先週追加された
onClick: () => void;
};
export function Button({
label,
variant = 'primary',
size = 'medium',
isDisabled = false,
isLoading = false,
showBackIcon = false,
showForwardIcon = false,
showDownloadIcon = false,
showUploadIcon = false,
showMenuIcon = false,
onClick,
}: ButtonProps) {
const handleClick = () => {
// ローディング中は何もしない(あれ、disabledでも制御してるけど...)
if (isLoading) return;
onClick();
};
// variant × size の組み合わせでクラスが増えていく
const variantClasses: Record<string, string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
tertiary: 'bg-transparent text-blue-600 hover:bg-blue-50',
danger: 'bg-red-600 text-white hover:bg-red-700',
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100',
};
const sizeClasses: Record<string, string> = {
small: 'px-2 py-1 text-sm',
medium: 'px-4 py-2 text-sm',
large: 'px-6 py-3 text-base',
};
const baseClasses =
'inline-flex items-center justify-center rounded font-medium';
const stateClasses = [
isDisabled && 'opacity-50 cursor-not-allowed',
isLoading && 'cursor-wait',
]
.filter(Boolean)
.join(' ');
const className = [
baseClasses,
variantClasses[variant],
sizeClasses[size],
stateClasses,
]
.filter(Boolean)
.join(' ');
return (
<button
className={className}
disabled={isDisabled || isLoading}
onClick={handleClick}
>
{/* 左側アイコン群: どれか1つだけ表示されるはず...? */}
{showBackIcon && <Icon name="arrow_left" />}
{showDownloadIcon && <Icon name="download" />}
{showUploadIcon && <Icon name="upload" />}
{showMenuIcon && <Icon name="menu" />}
{isLoading ? <Spinner /> : <span>{label}</span>}
{/* 右側アイコン: 次へ進むボタン用 */}
{showForwardIcon && <Icon name="arrow_right" />}
</button>
);
} フラグ引数のアンチパターンを踏みやすい
Buttonコンポーネントが育ってしまうと、アンチパターン「フラグ引数」を招きやすい。
一目で見て結局どうすればいいのか分かりづらい上に、あり得ない状態を指定することもできてしまう。
{/* 呼び出し側: フラグの羅列で意図が読み取りづらい */}
<Button
label="戻る"
variant="secondary"
size="medium"
showBackIcon={true}
showForwardIcon={false}
showDownloadIcon={false}
showUploadIcon={false}
isDisabled={false}
isLoading={false}
onClick={goBack}
/>
{/* 排他的なはずのフラグを同時に指定できてしまう */}
<Button
label="???"
showBackIcon={true}
showForwardIcon={true}
showDownloadIcon={true}
onClick={() => {}}
/>
{/* ← 左矢印、右矢印、ダウンロードアイコンが全部表示される? */}
{/* そもそもこの組み合わせは意図されたものなのか? */} もし専用コンポーネントを作ることができていたら、意図が一目で分かる。
<BackButton onClick={goBack} /> ②開発者の認知負荷が増える
このコンポーネントを見てほしい。
export function ReservationForm() {
return (
<Button
label="予約を確定する"
variant="primary"
size="large"
showForwardIcon={true}
onClick={confirmReservation}
/>
);
} 以下の疑問が湧くだろう。
-
variant="primary"って何色になるんだっけ?今回の要件では新しい種類を追加したほうがいいのかな? -
size="large"って結局どれくらいの大きさ? -
showForwardIconってどこに何のアイコンが表示される?
この疑問を解消するためには、結局Buttonコンポーネントの実装を確認する必要がある→関心の分離ができていない。
■目的に特化してコンポーネントが実装されている場合
export function ReservationForm() {
return (
<ReservationConfirmButton onClick={confirmReservation} />
);
} 「予約確定ボタンね、分かった。」と一発で理解できるだろう。
見た目で共通化してしまったコンポーネントは
「用途」という切り口で分離できていないため、関連パラメータの意図の予測が困難になる。
③一箇所の修正が全箇所に影響する
「PC版管理画面ユーザー登録ボタンが小さくて押しにくい」という要望に応えるため、ボタンの大きさを変更する修正を行った。
// Button.tsx のスタイルを修正
export function Button({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
className="px-6 py-3 text-white bg-blue-600 rounded" // px-4 py-2 → px-6 py-3に変更
onClick={onClick}
>
{label}
</button>
);
} 上記のように修正してみたが、このコンポーネントは
- ログイン画面のログインボタン
- 予約画面の確定ボタン
- 管理画面のCSVダウンロードボタン
- 設定画面の保存ボタン
- 削除確認ダイアログの削除ボタン
で使われているため、全ての利用箇所で影響がないか確認する必要がある。
そして、「ログイン画面のログインボタンは今のままの大きさが良い」となった場合は
このButtonコンポーネント内でログインボタン用と登録ボタン用の分岐を吸収することになる。
適切なDRYは、影響範囲が推測容易である。
影響範囲が推測困難なコンポーネント、関数は間違いなくDRYに失敗している。
④「ここに追加すればいい」の悪循環が生まれやすい
- 開発者A(3ヶ月前):「ダウンロードボタンを作りたい。ボタンコンポーネントがあるな。ここに
shoDownloadIconを追加しよう。」 - 開発者B(1ヶ月前):「アップロードボタンを作りたい。ボタンコンポーネントがあるな。ここに
shoUploadIconを追加しよう。」 - 開発者C(今日):「メニューボタンを作りたい。ボタンコンポーネントがあるな。ここに
shoMenuIconを追加しよう。」
type ButtonProps = {
// 本来の意図
variant?: string;
size?: string;
// いつの間にか追加されたもの
isDisabled?: boolean; // ← まあこれは汎用的か
isLoading?: boolean; // ← これもまあ...
showBackIcon?: boolean; // ← ここから怪しい
showForwardIcon?: boolean; // ← なぜアイコンがここに?
showDownloadIcon?: boolean;// ← ダウンロード専用では?
showUploadIcon?: boolean; // ← アップロード専用では?
showMenuIcon?: boolean; // ← 完全に別物では?
showBadge?: boolean; // ← 通知バッジ?ボタンに?
badgeCount?: number; // ← もはや何のコンポーネント?
}; ではどうしたら良いのか?
筆者の回答は「一切共通化しない」である。
冒頭に上げた例ならば
- ReservationConfirmButton.tsx
- SignUpButton.tsx
- SaveSettingsButton.tsx
という要領で各コンポーネントに分割する。
規模によっては、コンポーネント化もせずにフォームコンポーネント内部に直接書いてもいいだろう。
理由は以下の通りである。
- デザインバグのほうが、機能バグよりも発生したときのリスクが低い
- 「実は修正した機能がログインボタンにも影響があって、ボタンが押せなくなってました」よりも「ログインボタンだけデザインの適用が漏れてました」というバグのほうがまだ許容できる
- 分散したものを後から統合するのは比較的楽であり、判断も迷いにくい
- 統合されてしまったものを剥がすほうが何倍も難しい
とはいえ「デザインの修正漏れもできれば避けたいから、共通化したい」という場合は
TailwindCSSのカスタムユーティリティやコンポーネントクラスを使おう。
使いすぎは注意ではあるが、「デザインの再利用」という目的で用意されたものなので
コンポーネント化するよりも適切である。
まとめ
- 見た目が似てるからといって共通化してはいけない
- 見た目ではなく、機能や目的でDRYの切り口を考える
- 迷うなら別々で実装し、後から共通化するほうがうまくいく(早すぎる共通化は避ける)