はじめに
ボタンやカードが複数の画面に繰り返し登場すると、共通コンポーネントとして切り出したくなります。最初は重複するコードが減って便利ですが、要件が増えるにつれて問題が生じることもあります。特定の画面だけで使うオプションが追加され、同時に指定できない props が生まれ、小さな修正が複数の画面に影響するようになります。
そこで必要なのは、何を共通化し、何を利用側に残すかを判断する基準です。この記事では、React と TypeScript を例に、再利用性と汎用性を考慮したコンポーネントの設計方法を説明します。サンプルは設計の構造に焦点を当て、CSS とプロジェクトの設定は省略します。
1. 再利用性と汎用性の範囲を最初に決める
この記事では、再利用性を「同じコンポーネントを複数の利用箇所で繰り返し使える度合い」、汎用性を「特定の画面や業務ルールへの依存が少なく、異なる文脈に適用できる度合い」として扱います。
両者は関連していますが、再利用するコンポーネントがあらゆる状況に対応する必要はありません。たとえば、決済金額と決済状態を表示する PaymentSummary は、複数の決済画面で再利用できます。しかし、ユーザープロフィールや検索結果まで表示する汎用カードにする必要はありません。
まず再利用する範囲を区別すると、責務を決めやすくなります。
| 範囲 | 例 | 担当する責務 |
|---|---|---|
| 共通 UI | Button、Card | 基本動作、見た目のバリエーション、コンテンツの配置 |
| 業務領域 | PaymentSummary、SaveProfileButton | 決済やプロフィールなど、その領域の意味とルール |
| 画面 | ProfilePage、CheckoutPage | データの取得、画面の構成、複数機能の連携 |
これは必ず従うべきフォルダー構成ではなく、責務を分けるための一例です。業務領域に特化したコンポーネントも、有効な再利用の単位になります。
2. 見た目よりも変更理由を基準に境界を分ける
見た目が似ているという理由だけで二つの UI を同じコンポーネントにまとめると、後から利用箇所を判別する条件分岐が増え続けることがあります。
商品カードとユーザーカードには、画像とタイトルを表示するという共通点があります。しかし、商品カードには価格・在庫・購入に関するルールがあり、ユーザーカードには権限・所属・連絡先に関するルールがあります。価格のルールが変わるたびにユーザーカードまで修正する必要があるなら、共通化の範囲を見直したほうがよいでしょう。
図1. カードに共通する見た目と、業務領域ごとのルールを分離します。
この場合は、カードの枠線や余白などの共通する見た目だけを切り出し、商品情報とユーザー情報の構成はそれぞれに残せます。一方、複数の商品画面で同じ価格表示ルールが繰り返されるなら、ProductPrice のように業務上の意味を持つ単位でまとめるほうが自然です。
コンポーネントを切り出す前に、次の点を確認します。
- 複数の利用箇所で、同じ意味のデータを扱っているか。
- 同じ理由で一緒に変更される部分か。
- 特定の利用箇所のための例外が、ほかの利用箇所の動作を複雑にしていないか。
- 切り出したコンポーネントの責務を、一文で説明できるか。
React の公式ドキュメントでも、責務に応じて UI をコンポーネントに分け、複雑になったらさらに小さな単位に分割する考え方が紹介されています。実際の境界は、コードの行数だけでなく、一緒に変更される責務も見て決めるとよいでしょう。Thinking in React
3. props を利用側が理解できる契約にする
共通コンポーネントの props は、内部実装を調整するスイッチである前に、利用側との契約です。名前を見れば何を渡すべきか、どの組み合わせが有効かを理解できることが重要です。
たとえば、ボタンの見た目のバリエーションを、それぞれ boolean で指定できるようにしたとします。
<Button isPrimary isSecondary isDanger>削除</Button>
このコードでは、どのスタイルを優先するのかが曖昧です。同時に選べないバリエーションであれば、一つの値で表現したほうが意図が明確になります。
図2. 同時に選べないスタイルは、一つの variant で表現します。
import type { ComponentPropsWithoutRef } from "react";
// この例では ref の転送は扱いません。
// 各 variant に対応する CSS クラスは別途定義します。
export type ButtonProps = ComponentPropsWithoutRef<"button"> & {
variant?: "primary" | "secondary" | "danger";
};
export function Button({
variant = "primary",
type = "button",
className = "",
children,
...rest
}: ButtonProps) {
return (
<button
{...rest}
type={type}
className={`button button--${variant} ${className}`}
>
{children}
</button>
);
}
variant は一度に一つだけ選べます。一方、disabled や aria-label、クリックイベントなどは、HTML のボタンが持つ属性を活用します。type のデフォルト値を button にしておくと、フォーム内で通常の操作ボタンを使ったときの意図しない送信を避けられます。送信用のボタンは type="submit" と明示できます。
ここで大切なのは、すべてのコンポーネントで残りの props を無条件に渡すルールを作ることではありません。この例はネイティブのボタンを薄くラップするコンポーネントなので、そのボタンの属性を受け付けています。業務コンポーネントでは、外部から許可する動作や属性の範囲をさらに絞ることもできます。
props の設計では、次の基準が役立ちます。
- 排他的なオプションは、ユニオン型などで選択できる組み合わせを制限します。
- 必要な情報は必須の props とし、自然な動作がある場合にだけデフォルト値を用意します。
- 基本 UI では標準の属性名を尊重し、業務コンポーネントでは
onConfirmのように行為の意味が伝わる名前を検討します。 -
isCheckoutPageのように画面名が付いたオプションが増えたら、その画面の責務が共通 UI に混ざっていないか確認します。
4. 変化する内容を組み合わせられるようにする
カードを共通化するときに、タイトル、画像、価格、バッジ、ユーザー情報、購入ボタンまで、すべてをオプションとして追加すると、コンポーネントが扱う組み合わせは急速に増えます。
共通するのがコンテンツの配置であれば、各領域に入れる内容を利用側から渡す方法が適している場合があります。
import type { ReactNode } from "react";
export type CardProps = {
header?: ReactNode;
children: ReactNode;
footer?: ReactNode;
};
export function Card({ header, children, footer }: CardProps) {
return (
<div className="card">
{header != null && <div className="card__header">{header}</div>}
<div className="card__body">{children}</div>
{footer != null && <div className="card__footer">{footer}</div>}
</div>
);
}
先ほど定義した Button と組み合わせると、次のように使えます。実際のファイルでは、各コンポーネントをインポートして使用します。
<Card
header={<h2>プロフィール</h2>}
footer={<Button variant="secondary">プロフィールを編集</Button>}
>
<p>名前と自己紹介を確認してください。</p>
</Card>
図3. 利用側が内容を渡し、Card は共通の配置を担当します。
Card は、自分の中にプロフィールと商品情報のどちらが入るのかを知りません。見出しの文言や見出しレベル、ボタンの動作は利用側が決めます。React では JSX を children などの props として渡すことで、このような構成を作れます。Passing Props to a Component
ただし、組み合わせの自由度が高くなると、利用箇所ごとに構成やアクセシビリティがばらつく可能性があります。繰り返し使う業務上のパターンは、ProductCard や ProfileCard でラップし、必要な構造を保証できます。デザインシステムの制約が厳しい場合は、すべての領域を自由にするより、許可する領域やバリエーションを限定したほうが管理しやすくなります。
5. UI と業務ロジックの依存方向を決める
複数の画面で使う Button の中に、プロフィール保存 API の呼び出しや特定のページへの遷移を入れると、ボタンを再利用するすべての箇所に、その業務への依存が付いて回ります。
基本的な UI は受け取った値やイベントを扱い、業務を理解している層で具体的な処理を接続する構成にできます。
// Button は前述のコンポーネントを使用します。
type SaveProfileButtonProps = {
saving: boolean;
onSave: () => void;
};
export function SaveProfileButton({
saving,
onSave,
}: SaveProfileButtonProps) {
return (
<Button
disabled={saving}
aria-busy={saving}
onClick={onSave}
>
{saving ? "保存中…" : "プロフィールを保存"}
</Button>
);
}
この構造では、Button はプロフィールの保存について知りません。SaveProfileButton は保存状態に応じた文言とボタンの状態を結び付け、呼び出し側が実際の保存処理や成功・失敗への対応を担当します。保存エラーをユーザーに伝える処理や重複リクエストの防止は、業務フローに合わせて別途実装する必要があります。
図4. 業務処理と UI の表現を分け、props とコールバックで接続します。
すべての業務ロジックをページのファイルに集める必要はありません。複雑なロジックは専用のカスタムフックやサービスモジュールに分けられます。大切なのは、共通 UI が特定業務の実装を知る必要のある状況を減らすことです。
6. 状態は、その調整が必要な場所で管理する
汎用コンポーネントを作るからといって、すべての状態を外に出す必要はありません。逆に、状態をコンポーネントの内部に隠せば、常に使いやすくなるわけでもありません。
たとえば、検索キーワードを検索結果の一覧、URL、リセットボタンで共有する場合、入力コンポーネントの内部だけに保存するのは難しくなります。この場合は、共通の親が値を所有し、入力コンポーネントに value と変更用のコールバックを渡す制御方式が適していることがあります。ほかの機能と連携する必要のない単独の UI であれば、内部の状態として管理する方法も選べます。
図5. 共有する状態の所有者を決め、値と変更リクエストをやり取りします。
複数のコンポーネントの状態を一緒に調整する際に、共通の親へ状態をリフトアップする方法や、制御コンポーネントと非制御コンポーネントの違いは、React の公式ドキュメントでも説明されています。Sharing State Between Components
実務では、同じ値を親と子がそれぞれ保存し、同期し続けなければならない構造に注意します。まず、どこが値の基準となる状態を所有するかを決め、ほかのコンポーネントがその値を受け取って表示する構成にすると、データの流れを理解しやすくなります。
7. 繰り返しを観察してから共通化し、違いが大きくなったら再び分ける
利用箇所がまだ一つしかない段階で、将来のあらゆる要件を想定すると、実際には使わないオプションや抽象化が増えることがあります。まず画面に必要な実装を作り、別の利用箇所が生まれたときに、共通する責務と異なる責務を比較する方法が実用的です。
たとえば、カードが繰り返し登場し始めたら、次の順序で検討できます。
- 二つの利用箇所で、実際に繰り返されている構造やルールを書き出します。
- データだけが異なるのか、動作や変更理由まで同じなのかを確認します。
- 共通する責務だけを切り出し、各画面の業務上の判断は利用側に残します。
- 二つの利用箇所のコードが読みやすくなったか比較します。
- 新しい要件のたびに例外用のオプションが増えるなら、境界を見直します。
「何回繰り返したら必ず切り出す」という絶対的な回数よりも、共通化した結果、変更や理解にかかるコストが減るかを基準に判断するとよいでしょう。チームにすでにデザインシステムがある場合は、既存コンポーネントの契約を先に確認します。
8. 使用例と動作を通して設計を確認する
コンポーネントのファイルだけを読んでも、API が使いやすいかどうかは判断しにくいものです。実際の使用例を書くと、不要な設定や曖昧な責務が見えてきます。
たとえば、「プロフィール保存ボタンを配置するために、画面の種類を指定し、複数の boolean を調整し、内部のデータ形式まで知る必要があるか」を確認します。そうであれば、ドキュメントが足りないだけでなく、API 自体が複雑になっている可能性があります。
テストは、内部実装よりもユーザーが体験する契約を中心に考えます。ボタンのテキストとアクセシブルな名前が適切か、有効な状態でクリックするとコールバックが呼ばれるか、無効な状態では動作しないかなどを確認します。キーボード操作、フォーカス表示、長い文言、エラー状態やローディング状態も、実際の利用箇所で確認する必要があります。ネイティブの要素を使っていても、スタイルや組み合わせ方がこれらの動作を妨げていないか確認が必要です。
共通コンポーネントを導入・変更する前に、次の項目を確認するとよいでしょう。
- 再利用する範囲と責務を説明できる。
- 特定の画面だけのための分岐が、共通 UI に蓄積していない。
- props の名前と型から、有効な使い方を理解できる。
- 変化する内容を props または組み合わせで表現できる。
- 状態の所有者とイベントの流れが明確になっている。
- 通常・無効・ローディング・エラーなど、必要な状態を検討している。
- キーボードや支援技術による利用を考慮している。
- 共通動作を変更したときに確認すべき利用箇所と使用例がある。
おわりに
再利用性と汎用性を考慮した設計とは、あらゆる状況を処理する巨大なコンポーネントを作ることではありません。繰り返される責務を見つけて安定した契約にまとめ、異なる要件を適切な層に残す取り組みです。
新しいコンポーネントを作るときは、「将来どこでも使えるか」とともに、「今、どの責務を共有していて、次の変更がどこに影響するか」を考えてみてください。利用側のコードから意図を読み取りやすく、必要な変更の範囲を予測できる構造は、保守性の向上にもつながります。
参考資料
- React 公式ドキュメント — Thinking in React:責務に応じて UI をコンポーネントに分け、データの流れと状態を設計する方法。
- React 公式ドキュメント — Passing Props to a Component:props によるデータの受け渡しと、children によるコンポーネントの組み合わせ。
- React 公式ドキュメント — Sharing State Between Components:共通の親への状態のリフトアップと、制御・非制御コンポーネントの違い。




