やあ、調子はどうだい?
日々、機能開発のチケットをバリバリ消化しつつ、「この汎用コンポーネント、別の画面で少しだけ見た目を変えたいって言われたんだけど、どう設計したもんだ……」と頭を悩ませていないかい?
わかるよ。その悩み、中級から一歩抜け出してシニアの領域に入ろうとしているエンジニアなら誰もが一度は通る道だ。
UIライブラリを作っているわけでもないのに、無駄に複雑なバリエーション用のProps(`isPrimary` とか `isLarge` とか)を増やしまくった結果、コンポーネントの型定義が化け物みたいになり、挙句の果てに「実際のデザインカンプと微妙に違うからパディングを2pxずらしたい」というデザイナーからの要望に屈して、また新しいPropを追加する……。そんな「Propsの地獄」を現場で見たことがないか?
今回は、そんな泥沼から抜け出し、「外部からのスタイリングのカスタマイズ性を担保しつつ、保守性の高いコンポーネントをどう設計するか」について、ReactとTypeScriptの神髄に迫りながら徹底的に解説していこう。
—
なぜ `style` と `className` の両方を受け入れるべきなのか?
結論から言おう。実務で汎用コンポーネント(ボタン、カード、レイアウト用のBoxなど)を作るなら、原則として `className` と `style` の両方を受け入れ、適切にマージできるように設計すべき だ。
なぜか? 理由はシンプルで、開発現場において「スタイリングの手法」はチームやプロジェクト、あるいは時代によって変わるからだ。
CSS Modulesを使うプロジェクトもあれば、Tailwind CSSが全盛の現場もある。あるいは、インラインスタイルで動的な座標計算をブチ込みたい瞬間だってある。
外部の親コンポーネントから「この子をちょっと右に寄せたい」「このボタンだけマージンを消したい」と思ったとき、コンポーネント側が `className` や `style` の受け渡しをケチっていると、親側で無駄な `div` でラップするという「クソコードの錬金術」を発動せざるを得なくなる。
DOMの標準属性である `className` と `style` を正しく型付けし、適切に内部のルート要素へフォワード(継承)してあげること。これが、息の長い優れたReactコンポーネントの第一歩なんだ。
—
型定義のベストプラクティス:再発明するな、拡張せよ
まずはTypeScriptの型定義の話だ。
初心者がやりがちなアンチパターンとして、以下のようなものがある。
// ❌ やっちゃダメな例:独自の型をイチから定義する
interface ButtonProps {
className?: string;
style?: React.CSSProperties;
label: string;
}
一見動くように見えるが、これではHTML本来が持っている膨大な属性(`onClick`、`disabled`、`aria-` など)がすっぽり抜け落ちてしまう。
React/TypeScript環境では、HTMLのネイティブ要素が持つ型をベースにしつつ、必要なものを拡張するのがプロの流儀だ。`ComponentPropsWithoutRef` や `ComponentPropsWithRef` を使いこなそう。
import React from ‘react’;
// ✅ シニア流:HTMLのbutton要素の型をベースに拡張する
export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
/ ボタンの中に表示するテキストや要素 /
children: React.ReactNode;
/ バリエーションの指定(必要最低限のロジック用Propsは残す) /
variant?: ‘primary’ | ‘secondary’;
}
これだけで、`onClick` や `type=”submit”`、さらにはアクセシビリティ系の属性まで、一切のボイラープレートを書かずに型安全を手に入れることができる。
—
ブラウザの裏側と「スタイルの衝突」をどう制するか
ここで少し視野を広げて、ブラウザが裏側でどう動いているか、そしてReactがそれをどう調停しているかを知っておこう。
Reactの `style` プロパティに渡すオブジェクトは、最終的にDOM要素の `element.style`(インラインスタイル)に直結する。
ここで重要になるのがCSSの詳細度(Specificity)と適用の優先順位だ。
ブラウザのレンダリングエンジンは、大まかに以下の順序でスタイルを解決する。
1. インラインスタイル(`style=”…”`)
2. IDセレクタ
3. クラスセレクタ・擬似クラス
4. 要素セレクタ
つまり、コンポーネント内部のCSS(CSS ModulesやTailwindなど)で定義されたクラスと、外部から `style` プロパティ経由で渡された値が競合した場合、インラインスタイルが圧倒的な強さで勝ち誇ることになる。
逆に、外部から渡された `className`(例: Tailwindの `mt-4` など)と、内部のコンポーネントが持つクラスが競合した場合は、CSSの読み込み順やセレクタの詳細度に依存するため、予期せぬスタイルの崩壊(いわゆるスタイル汚染や上書き漏れ)が起きる。
この混沌を綺麗に調停するのが、フロントエンドエンジニアの腕の見せ所というわけだ。
—
実践!コピペで使える堅牢なコンポーネント実装例
百聞は一見にしかず。現場でそのまま使える、最高に洗練された汎用 `Box`(あるいは `Card`)コンポーネントのコードを見てほしい。
ここでは、CSS Modulesと、クラス結合の定番ユーティリティである `clsx`(または `tailwind-merge`)を組み合わせたモダンなアプローチをとる。
import React from ‘react’;
import clsx from ‘clsx’; // クラス名を安全に結合するための鉄板ライブラリ
import styles from ‘./Box.module.css’;
// 1. HTMLのdiv要素の型を継承しつつ、独自の拡張を行う
export interface BoxProps extends React.ComponentPropsWithoutRef<'div'> {
/ 角丸を強調するかどうか /
rounded?: boolean;
}
export const Box = React.forwardRef
({ className, style, rounded = false, children, …rest }, ref) => {
// 2. 内部のデフォルトクラスと、外部から渡された className を結合する
// clsxを使うことで、条件付きクラスの付与や重複・矛盾するクラスの整理が爆発的に楽になる
const computedClassName = clsx(
styles.box, // コンポーネントが持つデフォルトのスタイル
{
[styles.rounded]: rounded, // 条件に応じたスタイルの切り替え
},
className // 外部から注入されたclassName(これが一番優先されるべき)
);
return (
{children}
);
}
);
// デバッグやReact DevToolsでの表示名のためにdisplayNameを設定するお作法
Box.displayName = ‘Box’;
このコードの何が優れているのか?
1. 完全な型安全と拡張性:`ComponentPropsWithoutRef<'div'>` により、親から `onClick` や `tabIndex` が渡されても完璧に型が通る。
2. スタイルのマージ戦略の明示:内部のデフォルトスタイルに対し、外部からの `className` は `clsx` の末尾で結合されるため、親側が意図した上書きが確実に効く。
3. refのフォワード:`React.forwardRef` を使っているため、親コンポーネントからDOMノードへの参照(`useRef`)を安全に取得できる。アニメーションやフォーカス制御が必要になったとき、ここで泣くことがなくなる。
—
シニアからの実践的なアドバイス:やり過ぎに注意しろ
最後に、実務でこの設計を導入するときの「現場のリアルな罠」について忠告しておこう。
何でもかんでも `className` と `style` を受け入れ、さらに内部の特定の子要素のスタイルまで外部からいじれるようにしようとするエンジニアがいる。
例えば、「カードのタイトル部分の文字色を変えるために `titleClassName` も用意しました!」みたいなやつだ。
やめろ。それはいわゆる「プロップ・ドリル(Props Drilling)の悪夢」の別形態であり、カプセル化の破壊だ。
コンポーネントは「ブラックボックス」であるべきだ。外部からスタイルを注入させたいのは、あくまでその「ルート要素のレイアウトや外部余白(マージンなど)」を親側でコントロールさせたいからであって、内部の細かいパーツの見た目を外部からぐちゃぐちゃにいじるためではない。
もし内部構造のスタイルを大幅に変えたくなったのであれば、それはPropsの拡張で何とかするのではなく、コンポーネントの設計自体を見直すか、コンパウンドコンポーネント(Compound Components)パターンを採用するタイミングだ。
—
まとめ
- `className` と `style` はケチらずに受け入れ、内部のルート要素に適切に流し込め。
- 型定義は `React.ComponentPropsWithoutRef` をベースに拡張せよ。
- スタイルの結合には `clsx` や `tailwind-merge` などのツールを挟み、予測可能な上書きを実現せよ。
- ただし、コンポーネントのカプセル化を破壊するような過剰なスタイル露出は避けること。
この原則を守るだけで、あなたの書くReactコードの品質は一段階跳ね上がり、チームメンバーや将来の自分から感謝されること間違いなしだ。
さあ、エディタを開いて、プロジェクトのイケてないコンポーネントたちをリファクタリングしに行こうぜ!

コメント