再利用可能なUIコンポーネントとは?設計・分割・状態管理・保守性まで詳しく解説
フロントエンド開発では、画面数が増えるほど、同じようなボタン、入力欄、カード、一覧項目、ダイアログ、通知、設定行が何度も現れるようになります。最初の段階では、それぞれの画面ごとに必要なものを個別に作っても、すぐには大きな問題が見えないことがあります。しかし、仕様変更が重なり、実装者が増え、長期運用が始まると、似ているのに少しずつ違うUIが増殖し始めます。すると、見た目の統一感が失われるだけでなく、修正時にどこまで影響するのか分かりづらくなり、修正漏れや実装の重複が起きやすくなります。
こうした問題を抑えながら、変更しやすく、理解しやすく、複数の画面で自然に使い回せる状態を作るために重要なのが、再利用可能なUIコンポーネントという考え方です。これは単に同じコードを何度も呼び出すという話ではなく、共通化すべき見た目、振る舞い、責務、状態の境界を整理し、それらを意味のある部品として設計することを指します。本記事では、再利用可能なUIコンポーネントの基本概念から、粒度の決め方、受け取り値の設計、表示バリエーション、状態管理、スタイル設計、複合部品の組み立て、そして実務での見直し方までを、長めの解説とコード例を交えながら段階的に整理していきます。
1. 再利用可能なUIコンポーネントとは
再利用可能なUIコンポーネントとは、複数の画面や複数の利用文脈で繰り返し使えるように設計されたUI部品のことです。ここで大切なのは、単に見た目が同じであることではありません。たとえばボタンであれば、保存、送信、削除、閉じるといった用途の違いがあっても、基本的な構造や操作感が安定していて、意味の差分だけを明快に切り替えられる状態が望ましいです。つまり再利用可能性とは、共通の見た目を持っていることよりも、役割が整理されていて、別の画面に持っていっても無理なく使えることを意味します。
実務では、この再利用可能という言葉が「何にでも対応できる万能部品」と誤って理解されることがあります。しかし、本当に多くの場面に耐える部品は、何でもできる部品ではなく、責務が明確で、使い方が読み取りやすく、似た役割の場面に自然に適用できる部品です。条件分岐や設定項目を増やせば一見柔軟には見えますが、それは同時に理解コストの増加も招きます。そのため、再利用可能なUIコンポーネントは、柔軟性そのものよりも、意味の安定と構造の明快さによって価値を持つと考えたほうが実務的です。
なぜ再利用可能性が重要なのか
再利用可能なUIコンポーネントが重要になる最大の理由は、変更に強くなるからです。たとえば、製品全体でボタンの余白を見直したい、入力欄のエラー表示の位置を揃えたい、カードの角丸や影の強さを統一したい、といった要望が出たとき、共通部品として整理されていれば変更箇所はかなり限定されます。反対に、似たような実装が各画面に散らばっていると、どこまで同じ仕様なのかも曖昧になり、修正漏れや微妙な差異が発生しやすくなります。再利用可能性は、初回実装の速さよりも、むしろ変更が積み重なった後に大きな効果を発揮します。
さらに、再利用可能な部品はチーム開発にも良い影響を与えます。新しい画面を作るメンバーが、既存の部品を組み合わせることで一定品質のUIを短時間で組み立てられるようになれば、個人差による実装の揺れも抑えやすくなります。つまり再利用可能なUIコンポーネントは、コード量の削減だけでなく、品質の均一化、判断コストの削減、チーム内の共通言語の形成にもつながります。開発規模が大きくなるほど、その価値は目立ちやすくなります。
2. 再利用可能なUIコンポーネントを考える前提
再利用可能なUIコンポーネントは、単に実装をまとめれば成立するものではありません。何を共通化し、何を画面固有の差分として残すのかを見極める前提がないまま部品化を進めると、表面上は共通部品に見えても、実際には特定画面でしか使えない不安定な部品になりやすくなります。ここでは、部品化に入る前に持っておくべき基本視点を整理します。
2.1 共通部分と差分部分を切り分ける
再利用可能な部品を考えるときに最初に見るべきなのは、どの要素が毎回共通し、どの要素が文脈によって変わるのかという点です。たとえばカード型UIなら、外枠、タイトル領域、本文領域、操作領域という構造は共通しやすい一方で、アイコンの有無、補助文の量、ラベル表現、ボタン数、状態表示の種類などは画面ごとの差分になりやすいです。この切り分けが曖昧なまま部品化すると、内部に条件分岐が増え、利用側も「この部品は今回どこまで使えるのか」を毎回読み解かなければならなくなります。
また、共通部分と差分部分は、見た目だけでなく責務でも分ける必要があります。たとえば表示用のカード部品が、APIレスポンスの整形や業務上の文言判断まで内部で処理し始めると、それは単なる表示部品ではなく、特定機能に強く依存した部品になります。逆に、データの整形や業務判断は外側で済ませ、部品側は「どう見せるか」だけに集中させると、別の一覧や別のデータ文脈にも適用しやすくなります。再利用性は、構造の共通化だけでなく、責務の整理によって支えられます。
2.2 部品化の目的を先に決める
部品化は、何のために行うのかが曖昧だと失敗しやすくなります。見た目を統一したいのか、同じ操作感を複数画面で共有したいのか、変更しやすい構造を作りたいのかによって、部品として持つべき範囲は変わるからです。たとえば単なる装飾の共通化なら、ごく薄い部品でも十分かもしれません。しかし、ラベル、エラー表示、補助文、必須表示、説明文の位置関係まで揃えたいなら、それは単なる装飾部品ではなく、意味ある構造単位として設計する必要があります。
実務では、似ているからという理由だけで部品化し、後から「思ったより使い回せない」「例外ばかり増える」という状況になることがよくあります。これを避けるには、部品化の段階で「どこまでを共通化の対象にするのか」「どの範囲の利用を想定しているのか」を明確にしておく必要があります。目的が明確であれば、抽象化の深さも決めやすくなり、部品として持つべき責務の範囲もぶれにくくなります。
| 観点 | 共通化しやすいもの | 個別に残しやすいもの |
|---|---|---|
| 見た目 | 余白、文字サイズ、角丸、状態表現 | 特定画面だけの強い装飾 |
| 振る舞い | 読込中、無効状態、基本操作 | 業務特有の条件分岐 |
| データ | 表示形式の共通パターン | APIごとの整形や計算 |
| 文脈 | 汎用的な配置や構造 | 画面固有の説明や導線 |
2.3 再利用性と分かりやすさを両立させる
再利用性を高めたいと考えると、多くの条件に対応できるよう部品を柔軟にしたくなります。しかし、設定項目や条件分岐を増やしすぎると、今度はその部品が「どう使うべきか分かりにくいもの」になってしまいます。これは実務で非常によくある問題で、理論上は多機能なのに、利用者が安心して呼び出せず、結果として別の部品が増えていく、という流れが起きがちです。柔軟さを増やすことと、扱いやすさを保つことは、しばしば両立が難しいのです。
そのため、再利用可能なUIコンポーネントでは、何でもできることよりも、使い方がすぐ分かることのほうが重要になる場面が多いです。少数の明確な表示種別に絞る、名前を読めば役割が分かるようにする、特殊ケースは別部品へ切り出す、といった判断によって、再利用性と分かりやすさの両方を保ちやすくなります。実務で長く使われる部品は、万能であることよりも、意味が安定していることのほうが価値になります。
3. UIコンポーネントの粒度はどう決めるべきか
再利用可能なUIコンポーネントを設計するとき、もっとも迷いやすいのが粒度の決定です。細かく分ければ共通化しやすそうに見えますが、細かすぎると組み立て負担が大きくなります。逆に大きな単位でまとめれば便利そうに見えても、文脈依存が強くなって別画面へ持っていきにくくなります。つまり粒度の設計は、実装の美しさだけでなく、再利用性と保守性の両方に影響する重要な判断です。
3.1 小さすぎる部品の問題
部品を細かく分けること自体は悪いことではありません。むしろ、ボタン、ラベル、入力欄、バッジのような基本部品は、設計の土台として非常に重要です。ただし、それを必要以上に細かくしすぎると、見出し、補助文、余白ラッパー、枠線ラッパー、状態表示、操作領域などがすべて別部品になり、画面側は毎回大量の小部品を組み立てる必要が出てきます。その結果、同じ意味のUIでも画面ごとに構造が微妙に変わり、共通化したつもりなのに統一感が失われることがあります。
さらに、小さすぎる部品は意味のまとまりを失わせることがあります。本来なら「ユーザーカード」や「通知項目」としてひとまとまりで扱いたいものが、細かな部品の寄せ集めになると、利用者は毎回その構造を頭の中で再構築しなければなりません。これは再利用というより、再組み立てに近い状態です。したがって、細かく分けることが常に正しいのではなく、「その単位自体に意味があるか」を基準に分割する必要があります。
3.2 大きすぎる部品の問題
反対に、大きすぎる部品もまた再利用性を損ないます。たとえば検索条件、一覧、空状態、読込中、エラー表示、ページ移動、詳細遷移までをすべて内包した巨大部品は、一見すると便利そうに見えます。しかし、要件が少し違う画面が出てきた瞬間に流用しづらくなり、結局はその部品を複製して別の似た部品を作ることになりやすいです。大きすぎる部品は、文脈を抱え込みすぎることで再利用ではなく分岐と複製を生みやすくなります。
また、大きな部品は受け取り値が多くなりやすく、内部条件分岐も増えやすいため、利用者にとっての理解コストも高くなります。結果として、その部品を使うより、新しく専用部品を作ったほうが早いと感じられてしまうことがあります。再利用可能な部品は、ただ存在しているだけでは意味がなく、利用者が安心して使えることが重要です。内部事情を知らないと使えない部品は、実務では徐々に避けられていきます。
3.3 粒度を決める実務的な視点
粒度を決めるときに有効なのは、「それ自体でひとつの意図として読めるか」という視点です。ボタンや入力欄のような最小部品だけでなく、通知項目、設定行、ユーザーカードのように、複数要素が集まってひとつの意味を持つ部品もあります。このように意味のまとまりで粒度を考えると、見た目の大きさではなく、利用者にとっての扱いやすさを基準に判断しやすくなります。部品の粒度は画面上の物理的サイズではなく、責務のまとまりで決めるほうが安定します。
もうひとつ重要なのは、将来どう変わるかを見ることです。いつも一緒に変更されるものは同じ部品にまとめやすく、変更タイミングが別々になりやすいものは分けたほうが保守しやすくなります。つまり粒度は今の見た目だけでなく、今後の変更パターンまで見越して決める必要があります。この視点を持つだけで、後から「分けておけばよかった」「まとめておけばよかった」と感じる場面をかなり減らせます。
- 単体で意味が通るかを見る
- その部品名から役割が想像できるかを確認する
- いつも一緒に変更される要素かを見る
- 画面側の組み立て負担が重くなりすぎていないか確認する
- 内部条件分岐が増えすぎていないか見直す
4. 受け取り値の設計は再利用性を大きく左右する
再利用可能なUIコンポーネントは、見た目の統一以上に、受け取り値の設計によって使いやすさが大きく変わります。どの情報を外から渡し、どの振る舞いを内部で吸収し、どの範囲を利用者の選択に委ねるのかが整理されていないと、部品はすぐに使いにくくなります。この章では、受け取り値をどう設計すると再利用性と分かりやすさを保ちやすいのかを、コード例も交えながら整理します。
4.1 受け取り値は多ければ柔軟というわけではない
部品を汎用化しようとすると、色、余白、角丸、影、幅、高さ、配置、アイコン位置、ローディング、無効状態など、多くの受け取り値を増やしたくなります。たしかに、設定項目を増やせば一見すると何にでも対応できそうに見えます。しかし、その柔軟さはそのまま利用者側の判断負担になります。呼び出すたびに「今回は何を渡すべきか」「この組み合わせは正しいのか」を考えなければならない部品は、実務ではむしろ使いにくくなります。
本当に使いやすい部品は、自由入力が多いものではなく、よくある利用パターンを自然に表現できるものです。たとえば色を自由な文字列で受け取るよりも、「主要」「補助」「危険」「文字」といった表示種別に整理したほうが、見た目の統一感を保ちやすく、利用者も迷いにくくなります。再利用性を高めたいなら、設定項目を増やすことより、選び方が分かりやすい設計にすることのほうが重要です。
例のファイル名: Button.tsx
type ButtonProps = {
children: React.ReactNode;
表示種別?: "主要" | "補助" | "危険" | "文字";
大きさ?: "小" | "中" | "大";
読込中?: boolean;
無効?: boolean;
操作時?: () => void;
};
export function Button({
children,
表示種別 = "主要",
大きさ = "中",
読込中 = false,
無効 = false,
操作時,
}: ButtonProps) {
return (
<button
data-kind={表示種別}
data-size={大きさ}
disabled={無効 || 読込中}
onClick={操作時}
>
{読込中 ? "処理中..." : children}
</button>
);
}
この例では、色や余白を自由入力にせず、意味の整理された選択肢へまとめています。利用者は細かな見た目を毎回調整するのではなく、部品の役割に合った値を選ぶだけで済むため、実務での使いやすさが高まります。
4.2 受け取り値の名前は役割が伝わるようにする
受け取り値の名前が曖昧だと、部品の使い方も曖昧になります。たとえば type や mode のような広すぎる名前は、一見すると便利そうでも、実際には「何を切り替える値なのか」が読み取りにくくなります。名前は単なる記号ではなく、その部品を外からどう理解させるかを決める重要な設計要素です。
そのため、できるだけ役割が伝わる名前を使うことが大切です。また、排他的な意味を持つものを複数の真偽値で表すと、組み合わせの解釈が曖昧になります。たとえば「主要である」と「危険である」を別々の真偽値にすると、両方が真のときにどう扱うべきかが分かりません。こうした場合は、ひとつの表示種別にまとめるほうが、意味もコードも整理しやすくなります。
例のファイル名: Button.tsx
type ButtonProps = {
children: React.ReactNode;
表示種別?: "主要" | "補助" | "危険";
読込中?: boolean;
無効?: boolean;
補助文?: string;
操作時?: () => void;
};
export function Button({
children,
表示種別 = "主要",
読込中 = false,
無効 = false,
補助文,
操作時,
}: ButtonProps) {
return (
<div>
<button disabled={無効 || 読込中} data-kind={表示種別} onClick={操作時}>
{読込中 ? "読込中..." : children}
</button>
{補助文 ? <p>{補助文}</p> : null}
</div>
);
}
ここでは type や mode ではなく、表示種別、読込中、無効、補助文 といった名前を使っています。名前だけで役割が読み取りやすいため、初めて使う人でも部品の意図を理解しやすくなります。
4.3 業務判断は受け取り値へ変換してから渡す
再利用可能なUIコンポーネントに業務ロジックを直接持ち込むと、その部品は急速に特定画面依存になります。たとえば、「承認権限があるなら押せる」「期限を過ぎていたら文言を変える」「下書きなら強調色にする」といった判断を部品内部で処理し始めると、その部品は見た目の部品ではなく、業務条件の集まりになってしまいます。これでは別画面で同じ見た目を使いたくても、再利用しにくくなります。
そのため、業務判断は親や上位の組み立て層で済ませ、その結果だけをUI向けの受け取り値へ変換して渡すほうが安定します。部品は「なぜ無効なのか」を知る必要はなく、「無効をどう見せるか」に集中すべきです。この分離ができていると、見た目の部品は長く使い回しやすくなります。
例のファイル名: UserActions.tsx
import { Button } from "./Button";
function UserActions({
canDelete,
isLocked,
onDelete,
}: {
canDelete: boolean;
isLocked: boolean;
onDelete: () => void;
}) {
const 削除不可 = !canDelete || isLocked;
return (
<Button
表示種別="危険"
無効={削除不可}
操作時={onDelete}
>
削除
</Button>
);
}
この例では、削除権限やロック状態の判断は親側で行い、その結果だけを 無効 として渡しています。Button 自体は業務上の意味を知らず、UI表現だけに責任を持つため、別の画面でも同じ形で再利用しやすくなります。
4.4 受け取り値の組み合わせを増やしすぎない
受け取り値は個別には分かりやすくても、組み合わせが多すぎると部品全体の理解が難しくなります。表示種別、大きさ、行内表示、幅いっぱい、アイコン位置、補助説明、余白調整、強調表示などを自由に組み合わせられるようにすると、理論上は便利でも、どの組み合わせが自然で、どこまでが想定内なのかが見えにくくなります。その結果、利用者は部品を使うたびに試行錯誤することになります。
この問題を防ぐには、許可する組み合わせ自体を減らすことが有効です。何でもできるようにすることより、自然に使える範囲をはっきり絞ることのほうが大切です。再利用可能なUIコンポーネントは、単なる設定の箱ではなく、利用者の判断を減らすための設計でもあります。制約を設けることは不自由にするためではなく、長く扱いやすい部品にするための整理です。
例のファイル名: ButtonGroup.tsx
import { Button } from "./Button";
export function ButtonGroup() {
return (
<div>
<Button 表示種別="主要">保存</Button>
<Button 表示種別="補助">戻る</Button>
<Button 表示種別="危険">削除</Button>
</div>
);
}
このように、部品側で使い方の軸をある程度絞っておくと、画面ごとの見え方が揺れにくくなります。自由な組み合わせを増やしすぎるより、想定された使い方がすぐ分かる構造のほうが、実務でははるかに扱いやすいです。
5. 表示バリエーションはどう設計するべきか
同じ部品を複数の場面で使う以上、見た目の差分をどう扱うかは避けて通れません。しかし、表示バリエーションを無秩序に増やしていくと、部品はすぐに例外だらけになり、どれを使えばよいのか分かりにくくなります。ここでは、表示バリエーションをどのように整理すれば、再利用性と一貫性の両方を保ちやすいかを見ていきます。
5.1 バリエーションは意味の違いから設計する
表示バリエーションを考えるときに重要なのは、見た目の差から出発するのではなく、役割や意味の差から出発することです。たとえばボタンであれば、主要な行動、補助的な行動、危険な行動、装飾を抑えた文字行動といった意味の違いがあります。このように意味ベースで分類すると、画面を作る側は色の印象ではなく、その操作がどの程度の重みを持つのかを基準に選べるようになります。これは見た目の統一だけでなく、情報設計の一貫性にもつながります。
反対に、「濃いボタン」「薄いボタン」「青いボタン」のように見た目だけで分類すると、利用基準が曖昧になります。その結果、同じ意味の操作なのに画面ごとに見た目が違う、あるいは異なる意味の操作が似た見た目で並ぶ、といった問題が起きやすくなります。再利用可能なUIコンポーネントでは、見た目は意味を支えるためにあり、意味の差がまず先に整理されていることが重要です。
例のファイル名: Button.tsx
type 表示種別 = "主要" | "補助" | "危険" | "文字";
type ButtonProps = {
children: React.ReactNode;
表示種別?: 表示種別;
};
export function Button({
children,
表示種別 = "主要",
}: ButtonProps) {
return <button data-kind={表示種別}>{children}</button>;
}
この例では、見た目の濃さや色名ではなく、操作の意味を表す 表示種別 を受け取っています。利用側は「青い見た目にしたいから選ぶ」のではなく、「これは主要な操作か、補助的な操作か」で選べるため、判断基準が安定しやすくなります。
5.2 表示ルールは部品内で吸収する
表示バリエーションを利用側で細かく指定させると、画面ごとの揺れが増えやすくなります。そのため、色、背景、境界線、文字の強さ、無効時の表現などは部品側でルールとして持ち、利用側は意味だけを選ぶ構造にしたほうが安定します。これにより、同じ表示種別ならどの画面でも同じ印象を保ちやすくなり、デザイン変更が入っても部品内の調整だけで広く反映しやすくなります。
たとえば、表示種別ごとに対応するクラス名を部品側で管理しておけば、利用者は細かな色指定を意識せずに役割だけで選べます。これは、UI全体の統一感と実装のしやすさの両方に効く設計です。見た目の判断を各画面へ分散させず、部品の内部へ集約することが、再利用性を高めるうえで大切です。
例のファイル名: Button.tsx
const 表示種別クラス = {
主要: "button button--primary",
補助: "button button--secondary",
危険: "button button--danger",
文字: "button button--text",
};
export function Button({
children,
表示種別 = "主要",
}: {
children: React.ReactNode;
表示種別?: keyof typeof 表示種別クラス;
}) {
return (
<button className={表示種別クラス[表示種別]}>
{children}
</button>
);
}
ここでは、利用側は 表示種別="危険" のように意味だけを指定し、実際のクラス名の対応は部品側で吸収しています。こうしておくと、たとえば危険ボタンの色を変更したい場合でも、各画面を直す必要がなく、部品側のルールを修正するだけで全体に反映できます。
例のファイル名: UserForm.tsx
import { Button } from "./Button";
export function UserForm() {
return (
<div>
<Button 表示種別="主要">保存する</Button>
<Button 表示種別="補助">下書き保存</Button>
<Button 表示種別="文字">キャンセル</Button>
</div>
);
}
この利用例では、画面側は色や線の有無を意識せず、操作の役割だけを選んでいます。表示ルールが部品内に閉じているため、複数画面で同じ意味の操作を同じ印象で見せやすくなります。
5.3 単発の要望でバリエーションを増やしすぎない
実務では、「この画面だけ少し特別な見た目が欲しい」という要望が頻繁に出ます。もちろん個別事情に対応することは必要ですが、そのたびに新しい表示種別を追加していると、部品はすぐに肥大化します。最初は数種類だった表示種別が、数か月後には十種類以上に増え、どれが何のための種別なのか分からなくなることも珍しくありません。こうなると、部品は柔軟になったのではなく、むしろ利用判断が難しいものへ変わってしまいます。
そのため、新しいバリエーションを追加する前には、それが本当に複数画面で再利用される意味のある差分なのかを見極める必要があります。一時的な事情であれば画面側で局所対応したほうが、全体の秩序を守りやすい場合もあります。再利用可能な部品は、すべての要望を飲み込む箱ではなく、共通ルールを保つための基盤でもあります。
例のファイル名: Button.tsx
type 表示種別 = "主要" | "補助" | "危険" | "文字";
type ButtonProps = {
children: React.ReactNode;
表示種別?: 表示種別;
className?: string;
};
export function Button({
children,
表示種別 = "主要",
className = "",
}: ButtonProps) {
const baseClass = {
主要: "button button--primary",
補助: "button button--secondary",
危険: "button button--danger",
文字: "button button--text",
}[表示種別];
return <button className={`${baseClass} ${className}`}>{children}</button>;
}
この形では、共通の意味バリエーションは 表示種別 として保ちつつ、どうしてもその画面だけ調整が必要な場合に限って className で局所対応できます。新しい表示種別を安易に追加せずに済むため、部品全体の秩序を保ちやすくなります。
例のファイル名: CampaignPage.tsx
import { Button } from "./Button";
export function CampaignPage() {
return (
<section>
<Button 表示種別="主要" className="campaign-only-spacing">
今すぐ申し込む
</Button>
</section>
);
}
この例では、キャンペーン画面だけ余白を少し変えたいという単発要望に対して、新しい 特別 バリエーションを追加していません。意味の分類はそのままにして、局所的な見た目調整だけを画面側で行うことで、共通部品の肥大化を防いでいます。
6. 状態管理とUIコンポーネントの責務をどう分けるか
再利用可能なUIコンポーネントでは、状態の持ち方と責務の境界が曖昧になると、部品がすぐに特定文脈へ引きずられます。見た目の部品に業務判断が入り込みすぎると、同じ見た目でも別機能では使いにくくなるからです。この章では、状態管理とUI部品の責務をどう分けると、再利用性と保守性の両方を保ちやすいかを整理します。
6.1 表示状態と業務状態を分けて考える
UI部品が扱う状態には、大きく分けて表示状態と業務状態があります。表示状態とは、開閉しているか、選択されているか、読込中か、無効かといった、画面上の見え方に近い状態です。一方、業務状態とは、承認済みか、権限があるか、期限切れか、保存済みかといった、アプリケーション全体の意味に近い状態です。この二つを混ぜて扱うと、UI部品が業務知識を抱え込みすぎてしまい、再利用しにくくなります。
再利用可能なUIコンポーネントに向いているのは、基本的には表示状態の表現です。業務状態そのものは親や上位層で判断し、その結果として「無効」「強調」「表示する」といった表現寄りの状態だけを渡すほうが、部品の責務は明確になります。こうしておくと、同じ部品を別の業務文脈でも自然に使いやすくなり、UI部品自体は画面表現に集中しやすくなります。
例のファイル名: SubmitButton.tsx
type SubmitButtonProps = {
読込中?: boolean;
無効?: boolean;
操作時?: () => void;
};
export function SubmitButton({
読込中 = false,
無効 = false,
操作時,
}: SubmitButtonProps) {
return (
<button disabled={無効 || 読込中} onClick={操作時}>
{読込中 ? "送信中..." : "送信する"}
</button>
);
}
この例の SubmitButton は、読込中 や 無効 といった表示寄りの状態だけを受け取っています。承認済みかどうか、権限があるかどうかといった業務状態はここでは扱っていないため、部品の責務が見た目の制御に限定され、別画面でも流用しやすくなります。
例のファイル名: ApprovalForm.tsx
import { SubmitButton } from "./SubmitButton";
export function ApprovalForm({
承認権限あり,
期限切れ,
保存中,
送信する,
}: {
承認権限あり: boolean;
期限切れ: boolean;
保存中: boolean;
送信する: () => void;
}) {
const 送信不可 = !承認権限あり || 期限切れ;
return (
<SubmitButton
読込中={保存中}
無効={送信不可}
操作時={送信する}
/>
);
}
こちらでは、承認権限あり や 期限切れ といった業務状態を親側で判断し、その結果だけを 無効 へ変換しています。UI部品に業務上の意味を持ち込まず、表現向けの状態に翻訳してから渡しているため、設計の境界が分かりやすく保たれています。
6.2 内部状態を持つ部品と外部制御される部品
部品には、自分の中で状態を持つものと、親から状態を受け取って表示だけを担うものがあります。たとえば一時的な開閉や簡単な表示切り替えのように、その部品の中で閉じている状態なら、内部状態を持たせることは自然です。しかし、フォーム入力値やモーダルの開閉のように、他の部品や画面全体と整合を取りたい状態は、外部制御にしたほうが扱いやすいことが多いです。状態が広い文脈とつながるほど、親側で一元管理したほうが整合性を保ちやすくなります。
たとえば、開閉状態を外から制御できるアコーディオン部品は、一覧やフォームの中でも扱いやすくなります。逆に完全に内部状態だけで動くと、他部品と連動させたい場面で制御しづらくなることがあります。状態の持ち方は、部品単体の便利さではなく、画面全体で扱いやすいかどうかを基準に考えることが大切です。
例のファイル名: Accordion.tsx
type AccordionProps = {
見出し: string;
開いている: boolean;
切替時: () => void;
children: React.ReactNode;
};
export function Accordion({
見出し,
開いている,
切替時,
children,
}: AccordionProps) {
return (
<section className="accordion">
<button className="accordion__trigger" onClick={切替時}>
{見出し}
</button>
{開いている ? (
<div className="accordion__content">{children}</div>
) : null}
</section>
);
}
この例では、アコーディオン自身は 開いている 状態を持たず、親から渡された値だけを表示へ反映しています。そのため、複数の部品をまとめて閉じる、特定条件で開く、URLや入力状態と連動させる、といった制御がしやすくなります。
例のファイル名: FaqList.tsx
import { useState } from "react";
import { Accordion } from "./Accordion";
export function FaqList() {
const [開いている項目, set開いている項目] = useState<string | null>("料金");
return (
<div>
<Accordion
見出し="料金"
開いている={開いている項目 === "料金"}
切替時={() =>
set開いている項目(開いている項目 === "料金" ? null : "料金")
}
>
月額料金についての説明です。
</Accordion>
<Accordion
見出し="解約"
開いている={開いている項目 === "解約"}
切替時={() =>
set開いている項目(開いている項目 === "解約" ? null : "解約")
}
>
解約手続きについての説明です。
</Accordion>
</div>
);
}
この利用例では、どの項目が開いているかを親でまとめて管理しています。こうしておくと、「同時に一つだけ開く」といったルールも実装しやすくなり、部品ごとに状態が分散しないため、画面全体の整合が取りやすくなります。
6.3 部品内に業務ロジックを入れすぎない
UI部品の中に業務ルールを直接書き込むと、その部品は急速に特殊化します。たとえば、「申請中なら押せない」「権限がないなら非表示」「承認済みなら文言変更」といった条件を内部に増やしていくと、その部品はもはや汎用的なボタンやカードではありません。こうした構造では、似た見た目を別機能で使いたくても、前提条件が合わず、再利用できなくなります。見た目の部品と業務ロジックの境界が曖昧になることは、再利用性を下げる典型的な原因です。
そのため、実務では業務判断はできるだけ親や上位層で処理し、部品には表現に必要な状態だけを渡すほうが安定します。UI部品は「なぜその状態なのか」を知る必要はなく、「その状態をどう見せるか」に集中すべきです。この分離が守られているほど、同じ部品を別の画面や別の業務文脈に持っていきやすくなり、設計全体も読みやすくなります。
例のファイル名: StatusBadge.tsx
type StatusBadgeProps = {
表示種別: "通常" | "注意" | "完了";
children: React.ReactNode;
};
export function StatusBadge({
表示種別,
children,
}: StatusBadgeProps) {
return <span data-kind={表示種別}>{children}</span>;
}
この StatusBadge は、あくまで 表示種別 に応じた見え方だけを担当しています。申請が何段階目か、承認フローのどこにいるかといった業務知識を持たないため、見た目の部品として責務が明確です。
例のファイル名: RequestRow.tsx
import { StatusBadge } from "./StatusBadge";
export function RequestRow({
isApproved,
isExpired,
}: {
isApproved: boolean;
isExpired: boolean;
}) {
const 表示種別 = isApproved
? "完了"
: isExpired
? "注意"
: "通常";
const ラベル = isApproved
? "承認済み"
: isExpired
? "期限切れ"
: "確認中";
return <StatusBadge 表示種別={表示種別}>{ラベル}</StatusBadge>;
}
ここでは、isApproved や isExpired といった業務判断は親側で行い、その結果を 表示種別 と表示文言に変換して渡しています。これにより、StatusBadge は表示だけに集中でき、申請一覧以外の画面でも同じ部品を無理なく再利用しやすくなります。
7. スタイル設計と見た目の一貫性をどう保つか
再利用可能なUIコンポーネントは、機能面だけでなく見た目の一貫性も支える存在です。部品として存在していても、画面ごとに余白、文字サイズ、無効状態、強調方法が揺れていれば、実際には共通化の効果が十分に出ていないと言えます。この章では、スタイル設計をどのように考えると、部品体系全体が長く安定しやすいかを整理します。
7.1 基本スタイルは部品の責務として持つ
再利用可能な部品では、文字サイズ、余白、高さ、角丸、境界線、無効状態、読込中表示など、基本的な見た目は部品側が責任を持って定義したほうが安定します。こうしておけば、どの画面で使っても一定品質の見た目が得られ、呼び出し側が毎回細かな装飾を考えなくて済みます。部品として使う意味は、ただ呼び出せることではなく、呼び出した時点で共通の見た目と振る舞いが保証されることにあります。
反対に、部品を使うたびに外側から細かなスタイル上書きが必要になる構造では、同じ部品でも画面ごとに別物になっていきます。短期的には柔軟に見えても、長期的には共通部品としての信頼性を失いやすくなります。再利用可能なUIコンポーネントは、一定のスタイル規律を守るための境界でもあります。
例のファイル名: Button.tsx
type ButtonProps = {
children: React.ReactNode;
無効?: boolean;
読込中?: boolean;
操作時?: () => void;
};
export function Button({
children,
無効 = false,
読込中 = false,
操作時,
}: ButtonProps) {
return (
<button
className="button"
disabled={無効 || 読込中}
onClick={操作時}
>
{読込中 ? "処理中..." : children}
</button>
);
}
例のファイル名: Button.css
.button {
min-height: 40px;
padding: 0 16px;
border: 1px solid #d0d7de;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
background: #ffffff;
color: #111827;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
このように、基本スタイルを部品側へ閉じ込めておくと、呼び出すだけで一定の見た目が成立します。利用側が毎回余白や高さを決めなくてよいため、画面ごとの差が生まれにくくなります。
7.2 共通値を使って設計の言語をそろえる
色、余白、角丸、文字サイズなどをその場で数値や色コードとして書いていると、似ているが完全には同じでない値が増えやすくなります。そこで有効なのが、設計トークンのような共通値の考え方です。たとえば色を「主要色」「補助色」「警告色」、余白を「間隔小」「間隔中」「間隔大」といった意味ある単位で管理すると、部品同士が同じ設計言語の上で整いやすくなります。これにより、製品全体の印象がぶれにくくなり、部品の数が増えても一貫性を保ちやすくなります。
この考え方は、実装レベルでも効きます。たとえばCSS変数やテーマ定義を使って共通値を持っておけば、ブランドカラーや余白ルールが変わったときも、一か所の調整で広い範囲へ反映しやすくなります。個別部品へ直接数値を埋め込むのではなく、意味づけされた共通値を参照する構造にしておくことが、長期運用では重要です。
例のファイル名: tokens.css
:root {
--color-primary: #2563eb;
--color-danger: #dc2626;
--color-text: #111827;
--space-sm: 8px;
--space-md: 12px;
--radius-md: 10px;
--font-size-md: 14px;
}
例のファイル名: Button.css
.button {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
font-size: var(--font-size-md);
color: var(--color-text);
}
.button--primary {
background: var(--color-primary);
color: white;
}
.button--danger {
background: var(--color-danger);
color: white;
}
この形にしておくと、色や余白を単なる数値ではなく、意味を持つ設計語彙として扱えます。部品ごとの偶然の差が減り、製品全体で同じ判断基準を共有しやすくなります。
7.3 画面都合の上書きは慎重に扱う
実務では、「この画面だけ余白を狭くしたい」「ここだけ色味を少し変えたい」といった調整要望がよく出ます。もちろん、すべてを拒否する必要はありませんが、上書きを無制限に認めてしまうと、共通部品の一貫性は徐々に崩れていきます。同じ部品名なのに画面ごとに見え方が違う状態が増えると、利用者にとっても開発者にとっても、その部品の信頼性が下がります。
そのため、上書きが必要になったときは、それが本当に一時的な例外なのか、あるいは新しい共通パターンとして取り込むべき差分なのかを見極める必要があります。局所的な事情なら画面側で閉じた調整を選び、複数箇所で繰り返し現れる差分なら部品側の表示種別や構造を見直したほうが健全です。再利用可能な部品を守るとは、例外をなくすことではなく、例外に引きずられすぎないことです。
例のファイル名: Button.tsx
type ButtonProps = {
children: React.ReactNode;
className?: string;
};
export function Button({
children,
className = "",
}: ButtonProps) {
return <button className={`button ${className}`}>{children}</button>;
}
例のファイル名: CampaignPage.tsx
import { Button } from "./Button";
import "./CampaignPage.css";
export function CampaignPage() {
return (
<section>
<Button className="campaign-page__cta">
今すぐ申し込む
</Button>
</section>
);
}
例のファイル名: CampaignPage.css
.campaign-page__cta {
margin-top: 24px;
}
この例では、共通部品そのものを書き換えず、その画面だけの事情を局所的なクラスで閉じています。こうすると、例外が全体ルールを壊しにくくなり、本当に共通化すべき差分かどうかも見極めやすくなります。
7.4 一貫性は見た目だけでなく振る舞いにも及ぶ
見た目の一貫性というと、色や余白の統一に目が向きがちですが、実際には操作感や状態表現も同じくらい重要です。たとえば無効状態の見え方、読込中の文言、押下時の反応、エラー表示の位置が画面ごとに違うと、利用者は同じ製品の中で別々のルールを学ばされることになります。再利用可能なUIコンポーネントの価値は、見た目を揃えることだけでなく、こうした振る舞いの一貫性を繰り返し提供できることにもあります。
つまり、スタイル設計は単なる装飾管理ではなく、ユーザー体験の統一でもあります。同じ部品を使う意味は、同じ形を並べることよりも、同じ期待に対して同じ反応を返せることにあります。この視点を持つと、再利用可能な部品は見た目の効率化ツールではなく、製品体験の基盤として見えてきます。
例のファイル名: Button.tsx
type ButtonProps = {
children: React.ReactNode;
読込中?: boolean;
無効?: boolean;
操作時?: () => void;
};
export function Button({
children,
読込中 = false,
無効 = false,
操作時,
}: ButtonProps) {
return (
<button
className="button"
disabled={無効 || 読込中}
onClick={操作時}
aria-busy={読込中}
>
{読込中 ? "処理中..." : children}
</button>
);
}
例のファイル名: FormField.tsx
type FormFieldProps = {
ラベル: string;
エラー文?: string;
children: React.ReactNode;
};
export function FormField({
ラベル,
エラー文,
children,
}: FormFieldProps) {
return (
<div className="form-field">
<label className="form-field__label">{ラベル}</label>
{children}
{エラー文 ? (
<p className="form-field__error">{エラー文}</p>
) : null}
</div>
);
}
例のファイル名: FormField.css
.form-field__error {
margin-top: 6px;
font-size: 13px;
color: #dc2626;
}
このように、読込中の見せ方やエラー表示の位置まで部品として揃えておくと、見た目だけでなく操作時の期待も統一しやすくなります。結果として、利用者は画面ごとに新しいルールを学ばずに済み、製品全体を自然に使いやすく感じやすくなります。
8. 実装例で見る再利用可能なUIコンポーネントの考え方
ここまでの議論は設計原則が中心でしたが、実務では最終的にどのようなコード構造へ落とし込むかが重要です。この章では、基本部品から複合部品へどうつなげるのか、どのような形で責務を分離すると扱いやすいのかを、実装例を交えながら見ていきます。大切なのは、単に動くことではなく、その構造がなぜ再利用しやすいのかを読み取ることです。
8.1 小さな基本部品を土台にする
再利用可能なUIコンポーネントを安定して運用するには、まずボタン、入力欄、ラベル、バッジ、カード枠、補助テキストのような基本部品を整えることが有効です。これらの部品が見た目と挙動の両面で安定していれば、画面を作る側は細かな装飾や状態表現を毎回ゼロから考えなくて済みます。基本部品は、製品全体のUI語彙のような存在であり、後から作る複合部品や画面部品の土台になります。
ただし、基本部品だけですべてを組み立てようとすると、画面側の記述が細かくなりすぎることがあります。そのため実務では、基本部品を土台にしつつ、意味のある複合部品を上に積み重ねる構造が現実的です。最小部品を持つこと自体が目的なのではなく、それを使って複雑な画面を無理なく構築できることが重要です。再利用可能な部品体系は、単一の粒度ではなく、複数の層を持つことで安定しやすくなります。
例のファイル名: Button.tsx
type ButtonProps = {
children: React.ReactNode;
表示種別?: "主要" | "補助";
無効?: boolean;
操作時?: () => void;
};
export function Button({
children,
表示種別 = "主要",
無効 = false,
操作時,
}: ButtonProps) {
return (
<button
className={`button button--${表示種別}`}
disabled={無効}
onClick={操作時}
>
{children}
</button>
);
}
例のファイル名: TextInput.tsx
type TextInputProps = {
値: string;
入力時: (value: string) => void;
プレースホルダー?: string;
};
export function TextInput({
値,
入力時,
プレースホルダー,
}: TextInputProps) {
return (
<input
className="text-input"
value={値}
placeholder={プレースホルダー}
onChange={(e) => 入力時(e.target.value)}
/>
);
}
このような小さな基本部品を先に整えておくと、各画面で毎回 button や input の細部を直接書かずに済みます。結果として、見た目と振る舞いの基準が揃いやすくなり、後から複合部品を作るときも土台がぶれにくくなります。
8.2 複合部品は意味のまとまりで作る
複合部品を作るときは、単に基本部品を寄せ集めるのではなく、ひとつの意味ある表示単位として設計したほうが使いやすくなります。たとえばユーザーカード、通知項目、設定行、商品概要ブロックのような部品は、それ自体で役割が分かりやすく、複数画面で流用しやすいです。こうした部品を用意しておくと、画面側は「どう組み立てるか」より「何を表示するか」に集中しやすくなり、記述量だけでなく判断負担も減らせます。
たとえば基本部品を組み合わせて単純な設定行を作る場合、毎回ラベル、説明、入力欄、補助文をページ側で並べるのではなく、構造ごと複合部品へまとめておくと、配置の揺れを防ぎやすくなります。よく出る並び方を意味単位で部品化しておくことが、実務ではとても重要です。
例のファイル名: SettingField.tsx
type SettingFieldProps = {
ラベル: string;
説明?: string;
エラー?: string;
children: React.ReactNode;
};
export function SettingField({
ラベル,
説明,
エラー,
children,
}: SettingFieldProps) {
return (
<div className="setting-field">
<label className="setting-field__label">{ラベル}</label>
{説明 ? (
<p className="setting-field__description">{説明}</p>
) : null}
<div className="setting-field__control">{children}</div>
{エラー ? (
<p className="setting-field__error">{エラー}</p>
) : null}
</div>
);
}
例のファイル名: ProfileSettings.tsx
import { useState } from "react";
import { SettingField } from "./SettingField";
import { TextInput } from "./TextInput";
export function ProfileSettings() {
const [名前, set名前] = useState("");
return (
<SettingField
ラベル="表示名"
説明="プロフィールに表示される名前です。"
>
<TextInput
値={名前}
入力時={set名前}
プレースホルダー="山田 花子"
/>
</SettingField>
);
}
この形にしておくと、フォーム画面ごとにラベルや説明文の位置がずれにくくなります。複合部品は単なるまとめ書きではなく、繰り返し登場する構造を安定して提供するための器だと考えると分かりやすいです。
8.3 実装例:基本部品と複合部品の接続
次の例では、基本部品としての Button を使いながら、意味のまとまりとして UserCard を構成しています。ここで重要なのは、カード自体が表示構造に責務を絞っており、業務判断は外から渡されている点です。つまり、カードは「どう見せるか」を担当し、「なぜその状態か」は親側が判断しています。この分離が守られていると、部品の責務が明快になり、別の文脈へ持っていきやすくなります。
複合部品を作るときに大切なのは、基本部品をただ内包することではなく、表示単位としての意味を持たせることです。名前、説明、操作領域といった構造が一つのまとまりとして整理されていれば、画面側のコードも読みやすくなります。
例のファイル名: UserCard.tsx
import { Button } from "./Button";
type UserCardProps = {
名前: string;
説明?: string;
編集可能?: boolean;
編集時?: () => void;
};
export function UserCard({
名前,
説明,
編集可能 = false,
編集時,
}: UserCardProps) {
return (
<section className="user-card">
<header className="user-card__header">
<h2 className="user-card__title">{名前}</h2>
</header>
{説明 ? <p className="user-card__description">{説明}</p> : null}
{編集可能 ? (
<div className="user-card__actions">
<Button 表示種別="補助" 操作時={編集時}>
編集
</Button>
</div>
) : null}
</section>
);
}
例のファイル名: UserList.tsx
import { UserCard } from "./UserCard";
export function UserList() {
const 編集できる = true;
return (
<div>
<UserCard
名前="田中 一郎"
説明="管理者アカウントです。"
編集可能={編集できる}
編集時={() => console.log("編集")}
/>
</div>
);
}
この例では、「誰が編集できるか」という判断は UserList 側にあり、UserCard はその結果だけを受け取っています。こうすることで、UserCard は特定の業務ルールに縛られず、表示構造としての再利用性を保ちやすくなります。
8.4 例外対応で部品を壊さない
再利用可能な部品が崩れ始める典型例は、個別画面の例外要望に応じて、その場しのぎで受け取り値や条件分岐を増やし続けることです。最初は小さく扱いやすかった部品でも、数回の例外対応を経るだけで、何を渡せばどう表示されるのか分かりにくい複雑な部品へ変わってしまうことがあります。そうなると、共通部品であるはずなのに誰も安心して触れなくなり、結果として別の似た部品が増殖しやすくなります。
そのため、例外要望が出たときは、「これは本当に既存部品へ取り込むべき共通パターンか」「あるいは特殊部品として分けるべきか」を見直す必要があります。再利用性を守るには、何でもひとつの部品へ押し込むのではなく、部品体系そのものを定期的に整え直す姿勢が大切です。例外に合わせて既存部品を曲げ続けると、最後には共通部品が一番使いづらい存在になってしまいます。
例のファイル名: Button.tsx
type ButtonProps = {
children: React.ReactNode;
表示種別?: "主要" | "補助" | "危険";
className?: string;
操作時?: () => void;
};
export function Button({
children,
表示種別 = "主要",
className = "",
操作時,
}: ButtonProps) {
return (
<button
className={`button button--${表示種別} ${className}`}
onClick={操作時}
>
{children}
</button>
);
}
例のファイル名: CampaignAction.tsx
import { Button } from "./Button";
import "./CampaignAction.css";
export function CampaignAction() {
return (
<Button
表示種別="主要"
className="campaign-action__button"
操作時={() => console.log("応募")}
>
キャンペーンに応募する
</Button>
);
}
例のファイル名: CampaignAction.css
.campaign-action__button {
margin-top: 24px;
}
この例では、共通部品そのものに新しい表示種別や例外条件を増やさず、画面限定の差分を局所的なクラスで閉じています。こうした切り分けを意識すると、共通部品は共通部品として保ちやすくなり、例外対応による肥大化を防ぎやすくなります。
おわりに
再利用可能なUIコンポーネントは、単にコードを使い回すための技術ではなく、製品全体のUIを意味ある単位で整理し、変更しやすくし、チームで一貫した作り方を共有するための設計思想です。共通部分と差分部分を見極め、粒度を整え、受け取り値を分かりやすくし、表示バリエーションを増やしすぎず、状態と責務を分けることで、部品は初めて長く使える形になります。こうした設計は、一度決めて終わるものではなく、実装と運用の中で少しずつ磨かれていくものです。
実務では、抽象化しすぎても、逆に画面都合を飲み込みすぎても、部品は壊れやすくなります。そのため重要なのは、たくさん共通化することではなく、何を共通化すべきかを見極め続けることです。再利用可能なUIコンポーネントは、万能部品を一度作る作業ではなく、製品の変化に耐えられる部品体系を育てていく作業だと考えると、設計の方向性がぶれにくくなります。
EN
JP
KR