Read OSS

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.tsresolveProps ユーティリティは、単純なスプレッドではありません。以下のようなスマートなマージを行います:

  • slotscomponents はシャロウマージ(上書きではない)
  • slotPropscomponentsProps はスロットごとに再帰的にマージ
  • その他の props:明示的な props がデフォルト値より優先(undefined は「デフォルトを使う」を意味する)

packages/mui-system/src/DefaultPropsProvider/DefaultPropsProvider.tsxuseDefaultProps フックは、テーマオブジェクトを直接参照するのではなく、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.tsgenerateUtilityClass 関数は、コンポーネントスコープのクラスかグローバル状態クラスのどちらかを生成します:

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"
}

disabledfocusVisibleselectedexpanded などのグローバル状態には、共通の Mui- プレフィックスが使われます。これは意図的な設計です。どのコンポーネントが disabled 状態であっても、スタイルで &.Mui-disabled を指定すれば確実にターゲットにできます。

ステージ3:ユーザーのクラスと合成する。 packages/mui-utils/src/composeClasses/composeClasses.tscomposeClasses 関数が、生成されたクラスとユーザーの 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 サブコンポーネントを定義しています:ButtonRootButtonStartIconButtonEndIconButtonLoadingIndicatorButtonLoadingIconPlaceholder です。それぞれ 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 がルートにスプレッドされるため、onClickaria-*data-* などの DOM 属性もそのまま通過します。

次回予告

コンポーネントが内側からどのように構築されているかを一通り見てきました — props 解決、ownerState の構築、クラス生成、styled サブコンポーネント、そして最終的なレンダー。第4回では、あの (theme.vars || theme).palette.primary.main という参照を支えるテーマシステムを掘り下げます。クラシックな JS オブジェクトテーマと CSS カスタムプロパティの両方をサポートする、デュアルパスアーキテクチャの話です。