T-CREATOR

Emotion の「変種(variants)」設計パターン:props→ スタイルの型安全マッピング

Emotion の「変種(variants)」設計パターン:props→ スタイルの型安全マッピング

React アプリケーションでコンポーネントのスタイリングを行う際、ボタンの色やサイズなど、状態や用途に応じてスタイルを切り替える必要がありますよね。 Emotion と TypeScript を組み合わせることで、props から適切なスタイルへ型安全にマッピングする「変種(variants)」パターンを実装できます。 この記事では、Emotion における変種パターンの設計手法と、型安全性を保ちながら保守性の高いコンポーネントを作る方法をご紹介します。

背景

CSS-in-JS ライブラリである Emotion は、JavaScript でスタイルを記述できる強力なツールです。 従来の CSS や CSS Modules と異なり、コンポーネントのロジックとスタイルを同じ場所で管理でき、動的なスタイリングも容易に実現できます。

typescriptimport { css } from '@emotion/react';

// 基本的な Emotion のスタイル定義
const buttonStyle = css`
  padding: 8px 16px;
  border-radius: 4px;
  border: none;
`;

Emotion の基本的な使い方では、上記のように css 関数でスタイルを定義します。 この定義をコンポーネントに適用すると、スタイルが反映されます。

typescriptconst Button = () => {
  return <button css={buttonStyle}>クリック</button>;
};

しかし、実際のアプリケーション開発では、単一のスタイルだけでは不十分です。 primary ボタン、secondary ボタン、サイズの大小など、複数の「変種(variants)」を扱う必要があります。

変種パターンにおける主要な要素と関係を図で確認しましょう。

mermaidflowchart TB
  props["コンポーネント props"] -->|型で制約| variant["variant 値"]
  props -->|型で制約| size["size 値"]
  variant -->|マッピング| colorStyle["色スタイル"]
  size -->|マッピング| sizeStyle["サイズスタイル"]
  colorStyle -->|結合| finalStyle["最終スタイル"]
  sizeStyle -->|結合| finalStyle
  finalStyle -->|適用| component["レンダリング"]

この図が示すように、props の値が型システムによって制約され、適切なスタイルへマッピングされていきます。

課題

従来の方法で変種を実装すると、いくつかの課題が発生します。

型安全性の欠如

条件分岐で props を判定してスタイルを切り替える素朴な実装では、TypeScript の型チェックが十分に働きません。

typescript// ❌ 型安全でない実装例
const Button = ({ variant, children }: any) => {
  let style;

  // variant の値が文字列リテラルで制約されていない
  if (variant === 'primary') {
    style = primaryStyle;
  } else if (variant === 'secondary') {
    style = secondaryStyle;
  }

  return <button css={style}>{children}</button>;
};

上記のコードでは、variant に任意の文字列が渡される可能性があり、タイポや存在しない値を指定してもコンパイルエラーになりません。

typescript// これらはコンパイルエラーにならない
<Button variant="primari">送信</Button>  // タイポ
<Button variant="danger">削除</Button>   // 未定義の variant

保守性の低下

スタイルの定義が増えるたびに条件分岐を追加する必要があり、コードが肥大化します。

typescript// ❌ 条件分岐が増えて保守性が低い
const Button = ({ variant, size, disabled }: any) => {
  let style = baseStyle;

  if (variant === 'primary') {
    style = css([style, primaryStyle]);
  } else if (variant === 'secondary') {
    style = css([style, secondaryStyle]);
  } else if (variant === 'danger') {
    style = css([style, dangerStyle]);
  }

  if (size === 'small') {
    style = css([style, smallStyle]);
  } else if (size === 'medium') {
    style = css([style, mediumStyle]);
  } else if (size === 'large') {
    style = css([style, largeStyle]);
  }

  if (disabled) {
    style = css([style, disabledStyle]);
  }

  return <button css={style}>{children}</button>;
};

新しい variant や size を追加するたびに、条件分岐を追加する必要があります。 このような実装は可読性が低く、バグの温床になりやすいでしょう。

課題をまとめると以下のような構造になります。

mermaidflowchart LR
  issue1["型安全性の欠如"] -->|結果| bug1["タイポ・不正値"]
  issue2["条件分岐の増加"] -->|結果| bug2["保守性低下"]
  issue3["スタイル結合の複雑化"] -->|結果| bug3["可読性低下"]
  bug1 --> problem["実装上の問題"]
  bug2 --> problem
  bug3 --> problem

スタイル結合の複雑化

複数の variant を組み合わせる場合、スタイルの優先順位や上書きルールが複雑になります。

解決策

変種パターンでは、props の値をキーとしたオブジェクトマッピングを使い、型安全にスタイルを選択します。

型定義とスタイルマップの作成

まず、variant として許可する値を TypeScript の Union 型で定義します。

typescript// variant の型定義
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'small' | 'medium' | 'large';

この型定義により、指定できる値が制限され、タイポや不正な値を防げます。

次に、各 variant に対応するスタイルをオブジェクトとして定義します。

typescriptimport { css, SerializedStyles } from '@emotion/react';

// variant ごとのスタイルマップ
const variantStyles: Record<
  ButtonVariant,
  SerializedStyles
> = {
  primary: css`
    background-color: #007bff;
    color: white;
    &:hover {
      background-color: #0056b3;
    }
  `,
  secondary: css`
    background-color: #6c757d;
    color: white;
    &:hover {
      background-color: #545b62;
    }
  `,
  danger: css`
    background-color: #dc3545;
    color: white;
    &:hover {
      background-color: #bd2130;
    }
  `,
};

Record<ButtonVariant, SerializedStyles> 型により、すべての variant に対してスタイルが定義されていることが保証されます。

同様に、サイズのスタイルマップも作成しましょう。

typescript// size ごとのスタイルマップ
const sizeStyles: Record<ButtonSize, SerializedStyles> = {
  small: css`
    padding: 4px 8px;
    font-size: 12px;
  `,
  medium: css`
    padding: 8px 16px;
    font-size: 14px;
  `,
  large: css`
    padding: 12px 24px;
    font-size: 16px;
  `,
};

コンポーネントへの適用

型定義とスタイルマップを使って、型安全なコンポーネントを実装します。

typescriptimport React from 'react';

// Props の型定義
interface ButtonProps {
  variant?: ButtonVariant;
  size?: ButtonSize;
  children: React.ReactNode;
  onClick?: () => void;
}

Props の型を明示的に定義することで、コンポーネント利用時の型チェックが働きます。

typescript// コンポーネント実装
const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  size = 'medium',
  children,
  onClick,
}) => {
  // スタイルマップから適切なスタイルを取得
  const variantStyle = variantStyles[variant];
  const sizeStyle = sizeStyles[size];

  return (
    <button
      css={[baseStyle, variantStyle, sizeStyle]}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

この実装では、条件分岐を使わずオブジェクトのキーアクセスでスタイルを取得できます。 variantStyles[variant] という記述により、TypeScript は variant が正しい値であることを保証してくれます。

typescript// 基本スタイル
const baseStyle = css`
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s;
  font-weight: 600;

  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`;

解決策の全体像を図で確認しましょう。

mermaidflowchart TB
  type["Union 型定義"] -->|制約| props["Props 型"]
  styleMap["スタイルマップ<br/>Record 型"] -->|型安全| lookup["キーアクセス"]
  props -->|渡される| component["コンポーネント"]
  component -->|参照| lookup
  lookup -->|取得| styles["スタイル"]
  styles -->|結合| render["レンダリング"]

図で理解できる要点

  • Union 型が props を制約し、不正な値を防ぐ
  • Record 型のスタイルマップがすべての variant に対応
  • キーアクセスにより条件分岐なしでスタイル取得

具体例

実際のプロジェクトで使える完全な実装例をご紹介します。

完全な Button コンポーネント

以下は、variant、size、disabled 状態を持つ Button コンポーネントの完全な実装です。

typescript/** @jsxImportSource @emotion/react */
import { css, SerializedStyles } from '@emotion/react';
import React from 'react';

// 型定義
type ButtonVariant =
  | 'primary'
  | 'secondary'
  | 'danger'
  | 'success';
type ButtonSize = 'small' | 'medium' | 'large';

interface ButtonProps {
  variant?: ButtonVariant;
  size?: ButtonSize;
  disabled?: boolean;
  children: React.ReactNode;
  onClick?: () => void;
}

型定義を先頭にまとめることで、コンポーネントの仕様が一目でわかります。

typescript// 基本スタイル
const baseStyle = css`
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s;
  font-weight: 600;
  outline: none;

  &:focus {
    box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
  }

  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`;
typescript// variant スタイルマップ
const variantStyles: Record<
  ButtonVariant,
  SerializedStyles
> = {
  primary: css`
    background-color: #007bff;
    color: white;
    &:hover:not(:disabled) {
      background-color: #0056b3;
    }
  `,
  secondary: css`
    background-color: #6c757d;
    color: white;
    &:hover:not(:disabled) {
      background-color: #545b62;
    }
  `,
  danger: css`
    background-color: #dc3545;
    color: white;
    &:hover:not(:disabled) {
      background-color: #bd2130;
    }
  `,
  success: css`
    background-color: #28a745;
    color: white;
    &:hover:not(:disabled) {
      background-color: #1e7e34;
    }
  `,
};

各 variant のスタイルには、hover 状態や disabled 時の挙動も含めて定義します。

typescript// size スタイルマップ
const sizeStyles: Record<ButtonSize, SerializedStyles> = {
  small: css`
    padding: 4px 12px;
    font-size: 12px;
    min-width: 60px;
  `,
  medium: css`
    padding: 8px 16px;
    font-size: 14px;
    min-width: 80px;
  `,
  large: css`
    padding: 12px 24px;
    font-size: 16px;
    min-width: 100px;
  `,
};
typescript// Button コンポーネント
export const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  size = 'medium',
  disabled = false,
  children,
  onClick,
}) => {
  // マップからスタイルを取得
  const variantStyle = variantStyles[variant];
  const sizeStyle = sizeStyles[size];

  return (
    <button
      css={[baseStyle, variantStyle, sizeStyle]}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

このコンポーネントは、props の値に基づいて自動的に適切なスタイルが適用されます。

使用例

実装した Button コンポーネントの使用例を見てみましょう。

typescriptimport { Button } from './Button';

const App = () => {
  return (
    <div>
      {/* primary ボタン(デフォルト) */}
      <Button onClick={() => alert('送信')}>送信</Button>

      {/* secondary ボタン、large サイズ */}
      <Button variant='secondary' size='large'>
        キャンセル
      </Button>

      {/* danger ボタン、small サイズ */}
      <Button variant='danger' size='small'>
        削除
      </Button>

      {/* success ボタン、disabled 状態 */}
      <Button variant='success' disabled>
        保存済み
      </Button>
    </div>
  );
};

TypeScript により、存在しない variant や size を指定するとコンパイルエラーになります。

typescript// ❌ これらはコンパイルエラーになる
<Button variant="primari">送信</Button>  // タイポ
<Button variant="warning">警告</Button>  // 未定義の variant
<Button size="extra-large">大</Button>   // 未定義の size

使用時のデータフローを図で確認しましょう。

mermaidsequenceDiagram
  participant User as 開発者
  participant Component as Button コンポーネント
  participant TypeCheck as TypeScript
  participant StyleMap as スタイルマップ
  participant DOM as DOM

  User->>Component: props 渡し
  Component->>TypeCheck: 型チェック
  TypeCheck-->>Component: OK / Error
  Component->>StyleMap: variant でキーアクセス
  StyleMap-->>Component: スタイル取得
  Component->>StyleMap: size でキーアクセス
  StyleMap-->>Component: スタイル取得
  Component->>DOM: スタイル適用
  DOM-->>User: レンダリング

高度な実装:複合的な variant

複数の軸で variant を組み合わせる場合の実装例をご紹介します。

typescript// 複合的な variant の型定義
type ButtonState =
  | 'default'
  | 'loading'
  | 'success'
  | 'error';

interface AdvancedButtonProps extends ButtonProps {
  state?: ButtonState;
  fullWidth?: boolean;
}

state という新しい軸を追加し、ボタンの状態を表現できるようにしました。

typescript// state スタイルマップ
const stateStyles: Record<ButtonState, SerializedStyles> = {
  default: css``,
  loading: css`
    position: relative;
    color: transparent;
    pointer-events: none;

    &::after {
      content: '';
      position: absolute;
      width: 16px;
      height: 16px;
      top: 50%;
      left: 50%;
      margin-left: -8px;
      margin-top: -8px;
      border: 2px solid white;
      border-radius: 50%;
      border-top-color: transparent;
      animation: spinner 0.6s linear infinite;
    }

    @keyframes spinner {
      to {
        transform: rotate(360deg);
      }
    }
  `,
  success: css`
    background-color: #28a745;
    pointer-events: none;
  `,
  error: css`
    background-color: #dc3545;
    animation: shake 0.5s;

    @keyframes shake {
      0%,
      100% {
        transform: translateX(0);
      }
      25% {
        transform: translateX(-10px);
      }
      75% {
        transform: translateX(10px);
      }
    }
  `,
};

loading 状態ではスピナーアニメーションを、error 状態では振動アニメーションを表示します。

typescript// フルオプションの Button コンポーネント
export const AdvancedButton: React.FC<
  AdvancedButtonProps
> = ({
  variant = 'primary',
  size = 'medium',
  state = 'default',
  fullWidth = false,
  disabled = false,
  children,
  onClick,
}) => {
  const variantStyle = variantStyles[variant];
  const sizeStyle = sizeStyles[size];
  const stateStyle = stateStyles[state];

  // fullWidth のスタイル
  const widthStyle = fullWidth
    ? css`
        width: 100%;
      `
    : css``;

  return (
    <button
      css={[
        baseStyle,
        variantStyle,
        sizeStyle,
        stateStyle,
        widthStyle,
      ]}
      disabled={disabled || state === 'loading'}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

複数のスタイルマップから取得したスタイルを配列で結合することで、柔軟な組み合わせが可能になります。

typescript// 高度な使用例
const FormComponent = () => {
  const [submitState, setSubmitState] =
    useState<ButtonState>('default');

  const handleSubmit = async () => {
    setSubmitState('loading');

    try {
      await submitForm();
      setSubmitState('success');

      setTimeout(() => setSubmitState('default'), 2000);
    } catch (error) {
      setSubmitState('error');

      setTimeout(() => setSubmitState('default'), 2000);
    }
  };

  return (
    <AdvancedButton
      variant='primary'
      size='large'
      state={submitState}
      fullWidth
      onClick={handleSubmit}
    >
      {submitState === 'success' ? '送信完了' : '送信'}
    </AdvancedButton>
  );
};

このように、state を制御することで、非同期処理の状態をビジュアルで表現できます。

テーマ対応の実装

さらに発展させて、テーマ(ライトモード・ダークモード)に対応する方法をご紹介します。

typescriptimport { Theme } from '@emotion/react';

// テーマの型定義
interface AppTheme {
  mode: 'light' | 'dark';
  colors: {
    primary: string;
    secondary: string;
    danger: string;
    success: string;
  };
}
typescript// テーマを受け取るスタイルマップ
const createVariantStyles = (
  theme: AppTheme
): Record<ButtonVariant, SerializedStyles> => ({
  primary: css`
    background-color: ${theme.colors.primary};
    color: white;
    &:hover:not(:disabled) {
      filter: brightness(0.9);
    }
  `,
  secondary: css`
    background-color: ${theme.colors.secondary};
    color: white;
    &:hover:not(:disabled) {
      filter: brightness(0.9);
    }
  `,
  danger: css`
    background-color: ${theme.colors.danger};
    color: white;
    &:hover:not(:disabled) {
      filter: brightness(0.9);
    }
  `,
  success: css`
    background-color: ${theme.colors.success};
    color: white;
    &:hover:not(:disabled) {
      filter: brightness(0.9);
    }
  `,
});

テーマの色を関数で受け取ることで、動的にスタイルを生成できます。

typescriptimport { useTheme } from '@emotion/react';

// テーマ対応 Button
export const ThemedButton: React.FC<ButtonProps> = (
  props
) => {
  const theme = useTheme() as AppTheme;
  const variantStyles = createVariantStyles(theme);
  const variantStyle =
    variantStyles[props.variant || 'primary'];
  const sizeStyle = sizeStyles[props.size || 'medium'];

  return (
    <button css={[baseStyle, variantStyle, sizeStyle]}>
      {props.children}
    </button>
  );
};

useTheme フックを使うことで、現在のテーマに応じたスタイルを適用できます。

型安全性の検証

最後に、この実装がどのように型安全性を保証しているか確認しましょう。

#検証項目型による保護結果
1不正な variant 値Union 型で制限★★★
2スタイルマップの網羅性Record 型で保証★★★
3Props の必須・任意interface で明示★★★
4テーマの型整合性テーマ型定義★★★
typescript// TypeScript が検出するエラー例

// ❌ 型エラー: 'invalid' は ButtonVariant に存在しない
const button1 = <Button variant='invalid'>ボタン</Button>;

// ❌ 型エラー: variantStyles に 'warning' キーが存在しない
const variantStyles: Record<
  ButtonVariant,
  SerializedStyles
> = {
  primary: css`...`,
  secondary: css`...`,
  // danger と success が不足している
};

// ❌ 型エラー: children は必須
const button2 = <Button variant='primary' />;

// ✅ 正しい使い方
const button3 = (
  <Button variant='primary' size='large'>
    送信
  </Button>
);

TypeScript のコンパイラが、これらのエラーをすべて検出してくれます。

まとめ

Emotion における変種(variants)パターンの実装方法を解説しました。

この手法により、以下のメリットが得られます:

  • 型安全性: Union 型と Record 型により、不正な値や未定義のスタイルを防止
  • 保守性: 条件分岐を排除し、スタイルマップとして整理されたコード
  • 拡張性: 新しい variant の追加がスタイルマップへのエントリ追加だけで完結
  • 可読性: props とスタイルの対応関係が明確で、コードの意図が理解しやすい
  • テスタビリティ: 各 variant が独立しており、テストケースの作成が容易

条件分岐による実装と比較して、型システムを活用した変種パターンは、大規模なプロジェクトでも安全に保守できる設計といえるでしょう。

この記事で紹介したパターンは、Button 以外のコンポーネント(Card、Badge、Alert など)にも応用できます。 ぜひプロジェクトで実践してみてください。

関連リンク