Material UI コンポーネントの解剖学:Props からピクセルまで
前提知識
- ›第1回:アーキテクチャとパッケージレイヤー
- ›第2回:createStyled パイプライン
- ›React.forwardRef と refs
- ›TypeScript インターフェースの基礎
Material UI コンポーネントの解剖学:Props からピクセルまで
マクロアーキテクチャとスタイリングパイプラインを探ってきた2本の記事を経て、今回は単一のコンポーネントに焦点を当て、それらのシステムが実装レベルでどう組み合わさるかを見ていきましょう。Button はその格好のサンプルです — あらゆるパターンを示せるだけの複雑さを持ちながら、動作の説明が不要なほど身近なコンポーネントです。
すべての Material UI コンポーネントは、厳格な構造的規約に従っています。Button を理解すれば、ライブラリ内のどのコンポーネントも読み解くことができます。さっそく解剖してみましょう。
5ファイル構成の規約
すべてのコンポーネントディレクトリは、予測可能な構造に従っています。Button の場合はこうなっています:
| ファイル | 役割 |
|---|---|
Button.js |
実装 — styled サブコンポーネント + レンダー関数 |
Button.d.ts |
TypeScript 宣言 — 公開 API の型定義 |
buttonClasses.ts |
CSS クラストークン — 生成されたユーティリティクラス名 |
index.js |
再エクスポート — このコンポーネントの公開バレル |
index.d.ts |
TypeScript 再エクスポート — 公開型バレル |
index.js の中身はシンプルです:
export { default } from './Button';
export { default as buttonClasses } from './buttonClasses';
export * from './buttonClasses';
すべてのコンポーネントファイルは 'use client'; から始まります。これは React Server Components 向けのディレクティブで、Next.js などの RSC 対応フレームワークに対して「このモジュールはクライアント側での実行が必要」と伝えます。Material UI のコンポーネントはフックやブラウザ API を使用するため、クライアントで動作しなければなりません。
flowchart TD
barrel["@mui/material/Button/index.js"]
impl["Button.js — implementation"]
types["Button.d.ts — TypeScript API"]
classes["buttonClasses.ts — CSS tokens"]
barrel --> impl
barrel --> classes
types -.->|"types for"| impl
Props 解決チェーン
Button のレンダー関数には、3段階の props 解決チェーンがあります。Button.js の冒頭を見てみましょう:
const Button = React.forwardRef(function Button(inProps, ref) {
// Step 1: Read context props from ButtonGroup
const contextProps = React.useContext(ButtonGroupContext);
// Step 2: Merge context + direct props (direct wins)
const resolvedProps = resolveProps(contextProps, inProps);
// Step 3: Apply theme defaults
const props = useDefaultProps({ props: resolvedProps, name: 'MuiButton' });
const {
color = 'primary',
variant = 'text',
size = 'medium',
// ...
} = props;
flowchart LR
JSX["<Button color='primary' />"] -->|"inProps"| Resolve["resolveProps(contextProps, inProps)"]
Context["ButtonGroupContext"] -->|"contextProps"| Resolve
Resolve -->|"resolvedProps"| Defaults["useDefaultProps({ name: 'MuiButton' })"]
Theme["theme.components.MuiButton.defaultProps"] -->|"via DefaultPropsProvider"| Defaults
Defaults -->|"final props"| Destructure["Destructure with fallback defaults"]
packages/mui-utils/src/resolveProps/resolveProps.ts の resolveProps ユーティリティは、単純なスプレッドではありません。以下のようなスマートなマージを行います:
slotsとcomponentsはシャロウマージ(上書きではない)slotPropsとcomponentsPropsはスロットごとに再帰的にマージ- その他の props:明示的な props がデフォルト値より優先(
undefinedは「デフォルトを使う」を意味する)
packages/mui-system/src/DefaultPropsProvider/DefaultPropsProvider.tsx の useDefaultProps フックは、テーマオブジェクトを直接参照するのではなく、React context を使っています。DefaultPropsProvider がツリーをラップし、全コンポーネントのデフォルト props をまとめた context 値を提供します。これにより、レンダーのたびにテーマオブジェクト全体を参照するよりも高速に動作します。
ヒント: props の優先順位は「明示的な JSX props > React context props(ButtonGroup など)> テーマのデフォルト props > 分割代入のインラインデフォルト」の順です。props が期待通りに反映されない場合は、親の context が上書きしていないか確認してみましょう。
ownerState パターン
props が解決された後、Button は 529行目 で ownerState オブジェクトを構築します:
const ownerState = {
...props,
color,
component,
disabled,
disableElevation,
disableFocusRipple,
fullWidth,
loading,
loadingIndicator,
loadingPosition,
size,
type,
variant,
};
このオブジェクトは、コンポーネントの解決済み状態に関する唯一の信頼できる情報源です。すべての styled サブコンポーネントに prop として渡されます:
<ButtonRoot ownerState={ownerState} ... />
<ButtonStartIcon ownerState={ownerState} ... />
styled サブコンポーネントの内部では、スタイル関数から props.ownerState として ownerState にアクセスできます。第2回で紹介したバリアントマッチングシステムは props[key] と props.ownerState[key] の両方を参照します。そのため { props: { variant: 'contained' }, style: {...} } のようなバリアントスタイルが ownerState に対して正しくマッチします。
ただし、ownerState は絶対に DOM に届いてはいけません。20行目 の shouldForwardProp 関数がそれを防ぎます:
export function shouldForwardProp(prop) {
return prop !== 'ownerState' && prop !== 'theme' && prop !== 'sx' && prop !== 'as';
}
さらに、rootShouldForwardProp.ts の Material UI ルートレベルのフィルターが、もう一つの prop を除外します:
const rootShouldForwardProp = (prop: string) =>
slotShouldForwardProp(prop) && prop !== 'classes';
classes prop はルートレベルでフィルタリングされます。DOM 要素ではなく、ユーティリティクラスシステムが消費するものだからです。
CSS クラス生成システム
Material UI は、すべてのコンポーネントスロットと状態に対して決定論的な CSS クラス名を生成します。このシステムは3つのステージに分かれています。
ステージ1:クラストークンを定義する。 各コンポーネントは、クラスファイルにスロットを宣言します — buttonClasses.ts:
export function getButtonUtilityClass(slot: string): string {
return generateUtilityClass('MuiButton', slot);
}
const buttonClasses = generateUtilityClasses('MuiButton', [
'root', 'text', 'outlined', 'contained', 'focusVisible',
'disabled', 'sizeMedium', 'sizeSmall', 'sizeLarge', ...
]);
ステージ2:クラス名を生成する。 packages/mui-utils/src/generateUtilityClass/generateUtilityClass.ts の generateUtilityClass 関数は、コンポーネントスコープのクラスかグローバル状態クラスのどちらかを生成します:
export default function generateUtilityClass(
componentName: string,
slot: string,
globalStatePrefix = 'Mui',
): string {
const globalStateClass = globalStateClasses[slot as GlobalStateSlot];
return globalStateClass
? `${globalStatePrefix}-${globalStateClass}` // e.g., "Mui-disabled"
: `${ClassNameGenerator.generate(componentName)}-${slot}`; // e.g., "MuiButton-root"
}
disabled、focusVisible、selected、expanded などのグローバル状態には、共通の Mui- プレフィックスが使われます。これは意図的な設計です。どのコンポーネントが disabled 状態であっても、スタイルで &.Mui-disabled を指定すれば確実にターゲットにできます。
ステージ3:ユーザーのクラスと合成する。 packages/mui-utils/src/composeClasses/composeClasses.ts の composeClasses 関数が、生成されたクラスとユーザーの classes prop をマージします:
for (const slotName in slots) {
const slot = slots[slotName];
let buffer = '';
for (let i = 0; i < slot.length; i += 1) {
const value = slot[i];
if (value) {
buffer += (start ? '' : ' ') + getUtilityClass(value);
if (classes && classes[value]) {
buffer += ' ' + classes[value];
}
}
}
output[slotName] = buffer;
}
Array.join() を使わず、buffer による手動の文字列連結を採用しているのは、ホットパスにおけるパフォーマンスのマイクロ最適化です。
flowchart TD
Slots["slots = { root: ['root', 'contained', 'sizeMedium'] }"]
Gen["getButtonUtilityClass('root') → 'MuiButton-root'"]
User["classes = { root: 'my-custom-class' }"]
Result["'MuiButton-root MuiButton-contained MuiButton-sizeMedium my-custom-class'"]
Slots --> Gen
Gen --> Result
User --> Result
Button の 20行目 にある useUtilityClasses フックが、これらすべてをまとめます:
const useUtilityClasses = (ownerState) => {
const { color, disableElevation, fullWidth, size, variant, loading, ... } = ownerState;
const slots = {
root: [
'root',
loading && 'loading',
variant,
`size${capitalize(size)}`,
`color${capitalize(color)}`,
disableElevation && 'disableElevation',
fullWidth && 'fullWidth',
],
startIcon: ['icon', 'startIcon'],
endIcon: ['icon', 'endIcon'],
loadingIndicator: ['loadingIndicator'],
};
return composeClasses(slots, getButtonUtilityClass, classes);
};
スロット配列では条件付きのエントリー(loading && 'loading')を使っており、falsy な値は composeClasses によって自動的に無視されます。
Styled サブコンポーネントと overridesResolver
Button は5つの styled サブコンポーネントを定義しています:ButtonRoot、ButtonStartIcon、ButtonEndIcon、ButtonLoadingIndicator、ButtonLoadingIconPlaceholder です。それぞれ styled() で作成され、name/slot メタデータによって第2回で紹介した expression パイプラインと統合されます。
80行目 の overridesResolver は、テーマのスタイルオーバーライドキーをこの特定のサブコンポーネントにマッピングする関数です:
overridesResolver: (props, styles) => {
const { ownerState } = props;
return [
styles.root,
styles[ownerState.variant],
styles[`size${capitalize(ownerState.size)}`],
ownerState.color === 'inherit' && styles.colorInherit,
ownerState.disableElevation && styles.disableElevation,
ownerState.fullWidth && styles.fullWidth,
ownerState.loading && styles.loading,
];
},
ユーザーが theme.components.MuiButton.styleOverrides.contained を定義すると、第2回で紹介した styleThemeOverrides の末尾 expression がすべてのオーバーライドスロットを処理します。解決済みのオブジェクトはこの overridesResolver に渡され、現在の ownerState に基づいて Root サブコンポーネントに適用するスロットを選択します。
classDiagram
class ButtonRoot {
name: MuiButton
slot: Root
overridesResolver()
}
class ButtonStartIcon {
name: MuiButton
slot: StartIcon
overridesResolver()
}
class ButtonEndIcon {
name: MuiButton
slot: EndIcon
overridesResolver()
}
class ButtonLoadingIndicator {
name: MuiButton
slot: LoadingIndicator
}
class ButtonBase {
name: MuiButtonBase
slot: Root
}
ButtonRoot --> ButtonBase : extends via styled()
ヒント: スタイルオーバーライドが反映されないときは、対象サブコンポーネントの
overridesResolverを確認しましょう。styleOverridesで指定するキー(例:'startIcon')は、リゾルバーが返す値と一致している必要があります。多くのコンポーネントはdefaultOverridesResolverを定義しており、スロット名をstyles[slot]にそのままマッピングします。
レンダー関数
583行目 のレンダー関数が、これまでのすべてを組み合わせます:
return (
<ButtonRoot
ownerState={ownerState}
className={clsx(contextProps.className, classes.root, className, positionClassName)}
component={component}
disabled={disabled || loading}
ref={ref}
{...other}
classes={classes}
>
{startIcon}
{loadingPosition !== 'end' && loader}
{children}
{loadingPosition === 'end' && loader}
{endIcon}
</ButtonRoot>
);
いくつか注目すべき点があります。disabled prop は disabled || loading となっており、loading 状態は暗黙的にボタンを無効化します。classes prop は ButtonRoot(実体は ButtonBase)に渡され、クラス名がベースコンポーネントにカスケードされます。そして {...other} によって残りの props がルートにスプレッドされるため、onClick、aria-*、data-* などの DOM 属性もそのまま通過します。
次回予告
コンポーネントが内側からどのように構築されているかを一通り見てきました — props 解決、ownerState の構築、クラス生成、styled サブコンポーネント、そして最終的なレンダー。第4回では、あの (theme.vars || theme).palette.primary.main という参照を支えるテーマシステムを掘り下げます。クラシックな JS オブジェクトテーマと CSS カスタムプロパティの両方をサポートする、デュアルパスアーキテクチャの話です。