共通コンポーネントは機能を「足す」のではなく「素材から削り出す」イメージで作る
4/30/2026
フロントエンドで共通コンポーネントの Button を実装するとき、こんな感じで書いたことはないでしょうか。
type ButtonProps = {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
};
function Button({ children, onClick, disabled }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}この実装は、一見かなり自然に見えます。
children があって、クリックできて、無効化もできるので、最低限のボタンとしては十分に見えます。
ButtonProps に必要そうな props を定義し、それを button に渡しているので、共通コンポーネントとして成立しているように見えます。
共通コンポーネントを「よく使う部品をまとめるもの」と考えていると、この実装にはあまり違和感がありません。
しかし、この Button の実装は、実は良くありません。
なぜなら、button が本来持っている自由度を大量に削っているからです。
この props 定義では、type、name、value、aria-*、data-*、各種イベントハンドラなどが使えません。
共通コンポーネントは、一度いろいろな画面で使われ始めると、あとから自由度を戻すのが難しくなります。
例えば今後実装する画面で Button に type を付与したくなった場合、Button を修正する必要があります。
この状況で複数人、あるいは複数 AI エージェントが並列開発すると、各自が Button を変更し始めて、すぐコンフリクトや動作不備が出ます。
では、共通コンポーネントでは、何を制約してよくて、何を残すべきなのでしょうか。
「共通化したいから」という理由だけで、どこまで自由度を削ってよいのでしょうか。
Button のような基本的なコンポーネントでは、HTML がもともと持っている自由度と、デザイン言語として揃えたい部分をどう分ければよいのでしょうか。
私の考えでは、共通コンポーネントは、変わらない理由があるものだけを制約するべきです。
粘土細工ではなく、button の自由度を削る彫刻として考える
冒頭のような実装は、共通コンポーネントを「ゼロから props を足して形を作るもの」と捉えると自然に見えます。
まず props に何も渡さず button を描画する状態から始める。
次に、親から指定する必要がある要素だけを props に追加していく。
実際、私もこのように実装した経験はたくさんあります。
しかし、この捉え方では、props に書かなかったものが使えなくなることを見落としやすくなります。
type、name、value、aria-*、data-*、各種イベントハンドラは、button がもともと持っている自由度です。
それらを props に足さないという判断は、何もしていないのではありません。
button が本来持っている自由度を削っているということです。
だから、共通コンポーネントの実装は粘土細工ではなく、彫刻として考えるべきです。
出発点は何もない状態ではなく、HTML の button がすでに持っている自由度です。
共通コンポーネントを作るという行為は、その自由度のうち、不要な可能性を削っていくことです。
ボタンらしい見た目にするために、余白、色、角丸、状態変化などを揃えます。
仕様として壊れてはいけない挙動があるなら、その挙動も固定します。
彫刻として考えると、重要なのは、形を作ることではなく、削りすぎないことです。 一度削った自由度は簡単には戻せません。 利用箇所が増えれば増えるほど、あとからの変更コストは大きくなります。 だから、削るかどうかではなく、今ここで削っていいのかが本質的な問いになります。
ここまでで、共通コンポーネントを作ることは自由度を削ることだと見てきました。
では、何を削ってよいのでしょうか。
変わらない理由があるものだけを制約する
削ってよいものは、変わらない理由があるものだけです。 変わらない理由があるものは、制約してよいものです。 デザイン言語として統一されている色、余白、角丸、状態変化などは、制約してよい候補です。 domain / usecase、つまり仕様や利用目的の面で不変であり、壊れてはいけない挙動も、制約してよい候補です。
一方で、将来の機能追加で変わる可能性があるものは、削ってはいけません。 利用箇所ごとに違っていても不自然ではないものは、共通コンポーネント側で閉じるべきではありません。 未来の差分になり得るものは、最初から残しておくべきです。
変わり得るものは、完全に固定するのではなく、デフォルトを与えたうえで上書き可能にします。
margin、width、レイアウト上の配置、一部の装飾は、文脈で変わり得るものです。
これらは必要なら標準値を持たせてもよいですが、利用側が逃げられる余地を残します。
原則は「変わらない理由があるものだけ制約する」です。
では、共通化しようとしているものが本当に制約すべき対象かどうかは、どう判断すればよいのでしょうか。
Button で制約するものと残すものを分ける
「変わらない理由があるもの」の代表例は、プロジェクトで決まっている共通 UI デザインです。
ボタンの高さ、左右の余白、角丸、フォントサイズは、プロジェクトで共通の UI デザインを決めたので、1種類の形として揃えます。
focus-visible のリングや disabled 時の見た目も、プロジェクトで共通の UI デザインを決めたので、全画面で統一します。
また、ドメインやユースケース上あり得ない動作も、「変わらない理由があるもの」の代表例です。
たとえば業務要件として、削除操作は必ず確認ダイアログを開いてから実行する、と決まっている場合があります。
この場合、削除ボタンでは呼び出し側が直接削除処理を onClick に渡せないようにし、確認ダイアログを経由する props だけを受け取るようにします。
一方で、機能拡張や文脈で変わり得るものは制約せず残します。
ボタンの色は、画面の意味や導線ごとに変わるため、共通コンポーネント側で固定せず className で指定できるようにします。
type は、通常ボタン、送信ボタン、リセットボタンで変わるため、共通コンポーネント側で固定しません。
name、value、aria-*、data-* は、フォーム、アクセシビリティ、計測、テストの文脈で変わるため、利用側から渡せるようにします。
onClick 以外のイベントハンドラや className は、画面ごとの振る舞いや配置調整で必要になるため、逃げ道として残します。
判断基準を置いても、実装で意図せず自由度を削ることがあります。
では、Button ではどう実装すればよいのでしょうか。
React.ComponentProps で button の自由度を残す
冒頭の Button の例では、HTML 要素の自由度を基本的にそのまま通す方向で実装します。
React.ComponentProps<"button"> を使うと、button が本来受け取れる props を維持できます。
色のような文脈ごとに変えたい自由度は、独自 props を増やさず className で指定できるようにします。
type ButtonProps = React.ComponentProps<"button">;
const buttonShape = joinClassName(
// ボタンの高さ、左右の余白、角丸、フォントサイズ
"inline-flex h-10 items-center justify-center rounded-md px-4 text-sm font-medium",
// focus-visible のリング
"focus-visible:outline-2 focus-visible:outline-offset-2",
// disabled 時の見た目
"disabled:pointer-events-none disabled:opacity-50",
);
function Button({ className, ...props }: ButtonProps) {
return (
<button
className={joinClassName(className, buttonShape)}
{...props}
/>
);
}この実装では、button が本来持っている自由度は維持されます。
type、name、value、aria-*、data-*、各種イベントハンドラを利用側が必要に応じて使えます。
className も受け取ることで、共通コンポーネント側の標準スタイルに対して、文脈ごとの調整余地を残せます。
className によって、ボタンの色は画面ごとの意味に合わせて自由に指定できます。
一方で、プロジェクトで決めたデザインルールは破れません。
buttonShape によって高さ、余白、角丸、フォントサイズ、focus-visible、disabled 時の見た目を揃えられます。
これらは上書きできないため、破れません。
このようにすれば、原則通り「制約する理由があるものだけ制約し、それ以外は一切の制約を入れない」コンポーネントができます。