Geb's Moon Logo

Geb's Lab

共通コンポーネントは機能を「足す」のではなく「素材から削り出す」イメージで作る

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 定義では、typenamevaluearia-*data-*、各種イベントハンドラなどが使えません。

共通コンポーネントは、一度いろいろな画面で使われ始めると、あとから自由度を戻すのが難しくなります。 Buttontype を付与したくなった場合、Button を修正する必要があります。 この状況で複数人、あるいは複数 AI エージェントが並列開発すると、各自が Button を変更し始めて、すぐコンフリクトや動作不備が出ます。

では、共通コンポーネントでは、何を制約してよくて、何を残すべきなのでしょうか。 Button のような基本的なコンポーネントでは、HTML がもともと持っている自由度と、デザイン言語として揃えたい部分をどう分ければよいのでしょうか。

私の考えでは、共通コンポーネントは、変わらない理由があるものだけを制約するべきです。

粘土細工ではなく、button の自由度を削る彫刻として考える

冒頭のような実装は、共通コンポーネントを「ゼロから props を足して形を作るもの」と捉えると自然に見えます。 props に何も渡さず button を描画する状態から始める。 次に、親から指定する必要がある要素だけを props に追加していく。 実際、私もこのように実装した経験はたくさんあります。

しかし、この捉え方では、props に書かなかったものが使えなくなることを見落としやすくなります。 typenamevaluearia-*data-*、各種イベントハンドラは、button がもともと持っている自由度です。 それらを props に足さないという判断は、何もしていないのではありません。 button が本来持っている自由度を削っているということです。

だから、共通コンポーネントの実装は粘土細工ではなく、彫刻として考えるべきです。 HTML button がすでに持っている自由度です。 共通コンポーネントを作るという行為は、その自由度のうち、不要な可能性を削っていくことです。 ボタンらしい見た目にするために、余白、色、角丸、状態変化などを揃えます。 仕様として壊れてはいけない挙動があるなら、その挙動も固定します。

彫刻として考えると、重要なのは、形を作ることではなく、削りすぎないことです。

ここまでで、共通コンポーネントを作ることは自由度を削ることだと見てきました。

では、何を削ってよいのでしょうか。

変わらない理由があるものだけを制約する

削ってよいものは、変わらない理由があるものだけです。 domain / usecase

一方で、将来の機能追加で変わる可能性があるものは、削ってはいけません。

変わり得るものは、完全に固定するのではなく、デフォルトを与えたうえで上書き可能にします。 marginwidth、レイアウト上の配置、一部の装飾は、文脈で変わり得るものです。 これらは必要なら標準値を持たせてもよいですが、利用側が逃げられる余地を残します。

原則は「変わらない理由があるものだけ制約する」です。

では、共通化しようとしているものが本当に制約すべき対象かどうかは、どう判断すればよいのでしょうか。

Button で制約するものと残すものを分ける

「変わらない理由があるもの」の代表例は、プロジェクトで決まっている共通 UI デザインです。 UI 1 focus-visible のリングや disabled 時の見た目も、プロジェクトで共通の UI デザインを決めたので、全画面で統一します。

また、ドメインやユースケース上あり得ない動作も、「変わらない理由があるもの」の代表例です。 onClick に渡せないようにし、確認ダイアログを経由する props だけを受け取るようにします。

一方で、機能拡張や文脈で変わり得るものは制約せず残します。 className で指定できるようにします。 type は、通常ボタン、送信ボタン、リセットボタンで変わるため、共通コンポーネント側で固定しません。 namevaluearia-*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 が本来持っている自由度は維持されます。 typenamevaluearia-*data-*、各種イベントハンドラを利用側が必要に応じて使えます。 className も受け取ることで、共通コンポーネント側の標準スタイルに対して、文脈ごとの調整余地を残せます。 className によって、ボタンの色は画面ごとの意味に合わせて自由に指定できます。

一方で、プロジェクトで決めたデザインルールは破れません。 buttonShape によって高さ、余白、角丸、フォントサイズ、focus-visibledisabled 時の見た目を揃えられます。 これらは上書きできないため、破れません。

このようにすれば、原則通り「制約する理由があるものだけ制約し、それ以外は一切の制約を入れない」コンポーネントができます。