T-CREATOR

shadcn/ui の asChild/Slot を極める:継承可能な API 設計と責務分離

shadcn/ui の asChild/Slot を極める:継承可能な API 設計と責務分離

React でコンポーネントライブラリを設計する際、「柔軟性」と「型安全性」のバランスをどう取るかは永遠の課題です。shadcn/ui が採用している asChildSlot のパターンは、この課題に対する非常にエレガントな解決策を提供しています。

この記事では、shadcn/ui の根幹を支える Radix UI の asChildSlot の仕組みを深掘りし、継承可能な API 設計と責務分離の観点から、その設計思想と実装方法を徹底的に解説します。

背景

従来の as プロパティの限界

React のコンポーネントライブラリでは、レンダリングする HTML 要素を変更できるようにする as プロパティがよく使われてきました。

typescript// 従来の as プロパティの例
<Button as='a' href='/home'>
  ホームへ
</Button>

このアプローチは一見便利ですが、いくつかの問題を抱えています。

typescript// 型安全性の問題
interface ButtonProps {
  as?: React.ElementType;
  // どの要素でも受け入れるため、型推論が困難
}

主な課題は以下の通りです。

#課題詳細
1型安全性の欠如as で指定した要素固有のプロパティが型推論されない
2プロパティの競合親コンポーネントと子要素のプロパティが衝突する
3複雑なコンポーネントへの対応カスタムコンポーネントを渡す際の制約が多い
4ref の転送問題ref の適切な転送が困難

DOM の肥大化問題

もう一つの課題は、コンポーネントのラッピングによる DOM の肥大化です。

typescript// ラッピングによる DOM の肥大化
<Tooltip>
  <Dialog>
    <button>クリック</button>
  </Dialog>
</Tooltip>

// 結果として生成される DOM
<div> {/* Tooltip wrapper */}
  <div> {/* Dialog wrapper */}
    <button>クリック</button>
  </div>
</div>

この構造では、セマンティックではない div 要素が増え、アクセシビリティやパフォーマンスに悪影響を及ぼします。

以下の図は、従来のアプローチと asChild パターンの DOM 構造の違いを示しています。

mermaidflowchart TB
  subgraph old["従来のアプローチ"]
    tooltip1["Tooltip (div)"] --> dialog1["Dialog (div)"]
    dialog1 --> btn1["button"]
  end

  subgraph new["asChild パターン"]
    tooltip2["Tooltip<br/>(機能のみ)"] -.->|props/handlers| btn2["button"]
    dialog2["Dialog<br/>(機能のみ)"] -.->|props/handlers| btn2
  end

  style old fill:#ffeeee
  style new fill:#eeffee

図で理解できる要点:

  • 従来のアプローチでは各コンポーネントが DOM 要素を生成し、ネストが深くなる
  • asChild パターンでは機能のみを継承し、DOM はフラットに保たれる

課題

コンポーネント設計における責務の曖昧さ

React のコンポーネント設計では、「見た目の制御」と「振る舞いの提供」という 2 つの責務が混在しがちです。

typescript// 責務が混在している例
function Button({ onClick, className, children }) {
  // 見た目の制御(スタイル)
  const baseStyles =
    'px-4 py-2 bg-blue-500 text-white rounded';

  // 振る舞いの提供(クリックハンドラ)
  const handleClick = (e) => {
    console.log('Button clicked');
    onClick?.(e);
  };

  // どちらの責務も持っている
  return (
    <button
      className={`${baseStyles} ${className}`}
      onClick={handleClick}
    >
      {children}
    </button>
  );
}

この設計では、以下の問題が発生します。

  1. 要素の変更が困難button 以外の要素をレンダリングしたい場合に対応できない
  2. スタイルの上書きが複雑:基本スタイルとカスタムスタイルのマージロジックが必要
  3. 振る舞いの再利用が難しい:他のコンポーネントで同じ振る舞いを使いたい場合に重複が発生

プロパティマージの複雑性

複数のコンポーネントを組み合わせる際、プロパティのマージ処理は非常に複雑になります。

typescript// プロパティマージの課題
function MyButton({ onClick, className, ...props }) {
  // 親の onClick と子の onClick をどうマージする?
  // className の結合順序は?
  // aria-* 属性の優先順位は?

  return (
    <CustomComponent
      {...props}
      onClick={(e) => {
        onClick?.(e);
        // 親の処理も実行したい
      }}
      className={/* どう結合する? */}
    />
  );
}

特に以下の点が課題となります。

#プロパティタイプマージの課題
1イベントハンドラ両方実行するか、どちらを優先するか
2className結合順序による優先度の制御
3styleオブジェクトのマージと優先順位
4aria-* 属性アクセシビリティの保証
5ref複数の ref をどう転送するか

型安全性の確保

TypeScript を使用する場合、動的な要素の変更に対する型推論が課題です。

typescript// 型推論の課題
interface ButtonProps {
  as?: React.ElementType;
  children: React.ReactNode;
  // as="a" の場合は href が必要だが、型で表現できない
}

// 使用時に型エラーが出ない(本来は href が必要)
<Button as='a'>リンク</Button>;

この課題を解決するためには、条件型を駆使した複雑な型定義が必要になり、保守性が低下します。

以下の図は、プロパティマージにおける課題を可視化しています。

mermaidflowchart LR
  parent["親コンポーネント"] -->|onClick_A| merge["マージ処理"]
  parent -->|className_A| merge
  child["子コンポーネント"] -->|onClick_B| merge
  child -->|className_B| merge

  merge --> q1{"どちらを<br/>優先?"}
  merge --> q2{"どう<br/>結合?"}

  q1 -.->|課題| result["最終的な<br/>プロパティ"]
  q2 -.->|課題| result

  style merge fill:#ffeeee
  style q1 fill:#ffeeee
  style q2 fill:#ffeeee

図で理解できる要点:

  • 親と子で同じプロパティが存在する場合、優先順位の決定が必要
  • イベントハンドラと className では異なるマージロジックが求められる

解決策

asChild と Slot の基本コンセプト

Radix UI が提供する asChildSlot のパターンは、これらの課題を解決するために設計されました。基本的な考え方は以下の通りです。

責務の明確な分離

  • コンポーネントは「振る舞い」のみを提供
  • レンダリングする要素は利用者が決定
typescript// asChild パターンの基本形
import { Slot } from '@radix-ui/react-slot';

function Button({ asChild, ...props }) {
  // asChild が true なら Slot、false なら button をレンダリング
  const Comp = asChild ? Slot : 'button';
  return <Comp {...props} />;
}

このシンプルな実装により、以下が実現できます。

typescript// デフォルトの button として使用
<Button onClick={handleClick}>クリック</Button>

// カスタム要素として使用
<Button asChild>
  <a href="/home">ホームへ</a>
</Button>

Slot コンポーネントの仕組み

Slot コンポーネントは、React の cloneElement を利用して、プロパティを子要素にマージします。

typescript// Slot の内部実装のイメージ
function Slot({ children, ...props }) {
  if (React.isValidElement(children)) {
    return React.cloneElement(children, {
      ...props,
      ...children.props,
    });
  }
  return null;
}

この実装により、以下の処理が自動的に行われます。

プロパティのマージ

typescript// 親から渡されたプロパティ
<Button onClick={parentClick} className="parent-class" asChild>
  {/* 子のプロパティ */}
  <a onClick={childClick} className="child-class" href="/home">
    リンク
  </a>
</Button>

// 結果として生成される要素
<a
  onClick={/* childClick が優先 */}
  className="parent-class child-class"
  href="/home"
>
  リンク
</a>

イベントハンドラの優先順位

Radix UI の Slot 実装では、イベントハンドラに関して明確な優先順位ルールがあります。

typescript// イベントハンドラのマージロジック
function mergeEventHandlers(parentHandler, childHandler) {
  return (event) => {
    // 子のハンドラを先に実行
    childHandler?.(event);

    // 子で preventDefault されていなければ親も実行
    if (!event.defaultPrevented) {
      parentHandler?.(event);
    }
  };
}

このルールにより、以下の振る舞いが保証されます。

#状況結果
1親と子の両方にハンドラがある子が先に実行され、その後親が実行
2子で preventDefault() を呼ぶ親のハンドラは実行されない
3どちらか一方のみそのハンドラのみが実行される

className のマージ戦略

className のマージには tailwind-merge などのライブラリを使用することが推奨されます。

typescriptimport { twMerge } from 'tailwind-merge';

function Slot({ children, ...props }) {
  if (React.isValidElement(children)) {
    return React.cloneElement(children, {
      ...props,
      ...children.props,
      // 親と子の className をマージ
      className: twMerge(
        props.className,
        children.props.className
      ),
    });
  }
  return null;
}

これにより、Tailwind CSS のクラスが適切に上書きされます。

typescript// 親のスタイル
<Button className='bg-blue-500 px-4' asChild>
  {/* 子のスタイルで bg-red-500 が bg-blue-500 を上書き */}
  <a className='bg-red-500 py-2'>リンク</a>
</Button>

// 結果: bg-red-500 px-4 py-2

責務分離の実現

asChild パターンにより、コンポーネントの責務が明確に分離されます。

typescript// 振る舞いを提供するコンポーネント
function DialogTrigger({ asChild, ...props }) {
  const Comp = asChild ? Slot : 'button';

  return (
    <Comp
      // Dialog を開く振る舞いを提供
      onClick={openDialog}
      aria-haspopup='dialog'
      aria-expanded={isOpen}
      {...props}
    />
  );
}

利用者は見た目を自由に決定できます。

typescript// ケース1: デフォルトの button
<DialogTrigger>開く</DialogTrigger>

// ケース2: カスタムスタイルのボタン
<DialogTrigger asChild>
  <button className="custom-button">開く</button>
</DialogTrigger>

// ケース3: アイコンボタン
<DialogTrigger asChild>
  <IconButton icon={<MenuIcon />} />
</DialogTrigger>

以下の図は、asChild パターンにおける責務分離を示しています。

mermaidflowchart TB
  subgraph library["ライブラリの責務"]
    behavior["振る舞いの提供"]
    behavior --> onclick["onClick ハンドラ"]
    behavior --> aria["aria-* 属性"]
    behavior --> state["状態管理"]
  end

  subgraph user["利用者の責務"]
    appearance["見た目の決定"]
    appearance --> element["要素の選択"]
    appearance --> styling["スタイリング"]
    appearance --> content["コンテンツ"]
  end

  library -.->|asChild| merge["Slot による<br/>マージ"]
  user -.->|children| merge

  merge --> final["最終的な<br/>コンポーネント"]

  style library fill:#e3f2fd
  style user fill:#fff3e0
  style merge fill:#e8f5e9

図で理解できる要点:

  • ライブラリは振る舞い(onClick、aria 属性、状態管理)を提供
  • 利用者は見た目(要素、スタイル、コンテンツ)を決定
  • Slot が両者をマージして最終的なコンポーネントを生成

具体例

基本的な実装例

まず、asChild をサポートするシンプルなボタンコンポーネントを実装してみます。

パッケージのインストール

bashyarn add @radix-ui/react-slot

基本的な Button コンポーネント

typescriptimport { Slot } from '@radix-ui/react-slot';
import { forwardRef } from 'react';

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  asChild?: boolean;
}

ButtonProps インターフェースは、通常の button 要素のプロパティを継承し、asChild プロパティを追加しています。

typescriptconst Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ asChild = false, className, ...props }, ref) => {
    // asChild が true なら Slot、false なら button
    const Comp = asChild ? Slot : 'button';

    return (
      <Comp ref={ref} className={className} {...props} />
    );
  }
);

Button.displayName = 'Button';

forwardRef を使用することで、ref を適切に転送できます。これは、親コンポーネントが DOM 要素への参照を取得する際に必要です。

使用例

typescript// ケース1: デフォルトの button として使用
<Button onClick={() => console.log('clicked')}>
  クリック
</Button>

このケースでは、通常の button 要素がレンダリングされます。

typescript// ケース2: Next.js の Link として使用
import Link from 'next/link';

<Button asChild>
  <Link href='/dashboard'>ダッシュボードへ</Link>
</Button>;

asChild を指定することで、Button の振る舞いが Link コンポーネントに継承されます。

typescript// ケース3: アンカータグとして使用
<Button asChild>
  <a
    href='https://example.com'
    target='_blank'
    rel='noopener noreferrer'
  >
    外部リンク
  </a>
</Button>

a 要素を使用する場合、href などのアンカー固有のプロパティが適切に機能します。

スタイリングを含む実装例

shadcn/ui スタイルの、より実践的なボタンコンポーネントを実装します。

スタイル定義

typescriptimport {
  cva,
  type VariantProps,
} from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';

const buttonVariants = cva(
  // 基本スタイル
  'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default:
          'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive:
          'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline:
          'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        ghost:
          'hover:bg-accent hover:text-accent-foreground',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

class-variance-authority を使用することで、バリアントベースのスタイル管理が可能になります。

Button コンポーネントの実装

typescriptinterface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (
    { className, variant, size, asChild = false, ...props },
    ref
  ) => {
    const Comp = asChild ? Slot : 'button';

    return (
      <Comp
        className={twMerge(
          buttonVariants({ variant, size, className })
        )}
        ref={ref}
        {...props}
      />
    );
  }
);

twMerge を使用して、バリアントのスタイルとカスタムクラス名を適切にマージしています。

使用例

typescript// バリアントの使用
<Button variant='destructive' size='lg'>
  削除
</Button>
typescript// カスタムクラス名の追加
<Button className='w-full' variant='outline'>
  全幅ボタン
</Button>
typescript// asChild と組み合わせ
<Button asChild variant='ghost'>
  <Link href='/profile'>プロフィール</Link>
</Button>

複数コンポーネントの合成

asChild の真価は、複数のコンポーネントを合成する際に発揮されます。

Tooltip と Button の合成

typescriptimport {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';

function Example() {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger asChild>
          <Button variant='outline'>
            ホバーしてください
          </Button>
        </TooltipTrigger>
        <TooltipContent>
          <p>ツールチップの内容</p>
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}

TooltipTriggerasChild を指定することで、Button が Tooltip のトリガーとして機能します。DOM には余計なラッパー要素が生成されません。

Dialog と Button の合成

typescriptimport {
  Dialog,
  DialogContent,
  DialogTrigger,
} from '@/components/ui/dialog';

function Example() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button>ダイアログを開く</Button>
      </DialogTrigger>
      <DialogContent>
        <p>ダイアログの内容</p>
      </DialogContent>
    </Dialog>
  );
}

多重合成の例

typescript// Tooltip、Dialog、Button を同時に合成
<TooltipProvider>
  <Tooltip>
    <TooltipTrigger asChild>
      <Dialog>
        <DialogTrigger asChild>
          <Button variant='outline'>開く</Button>
        </DialogTrigger>
        <DialogContent>
          <p>ダイアログの内容</p>
        </DialogContent>
      </Dialog>
    </TooltipTrigger>
    <TooltipContent>
      <p>クリックでダイアログを開きます</p>
    </TooltipContent>
  </Tooltip>
</TooltipProvider>

この例では、Button に対して Tooltip と Dialog の機能が両方継承されています。

以下の図は、複数コンポーネントの合成フローを示しています。

mermaidflowchart TB
  tooltip["Tooltip<br/>(asChild)"] -->|onMouseEnter<br/>onMouseLeave| merge1["Slot マージ①"]
  dialog["Dialog<br/>(asChild)"] -->|onClick<br/>aria-haspopup| merge1

  merge1 -->|マージされた<br/>props| merge2["Slot マージ②"]
  button["Button"] -->|className<br/>variant| merge2

  merge2 --> final["最終的な button 要素"]

  final -.->|生成される HTML| dom["&lt;button<br/> onClick=...<br/> onMouseEnter=...<br/> aria-haspopup=...<br/> class=...&gt;"]

  style merge1 fill:#e8f5e9
  style merge2 fill:#e8f5e9
  style final fill:#fff3e0

図で理解できる要点:

  • 各コンポーネントが独自の振る舞い(イベントハンドラや aria 属性)を提供
  • Slot が段階的にプロパティをマージ
  • 最終的に単一の button 要素として DOM に出力

TypeScript での型安全な実装

asChild を使用する際、TypeScript の型推論を適切に機能させるための実装例です。

AsChildProps 型の定義

typescripttype AsChildProps<DefaultElementProps> =
  | ({ asChild?: false } & DefaultElementProps)
  | { asChild: true; children: React.ReactNode };

この型定義により、asChild の値に応じて異なるプロパティが要求されます。

Button コンポーネントへの適用

typescripttype ButtonPropsBase = {
  variant?: 'default' | 'destructive' | 'outline' | 'ghost';
  size?: 'default' | 'sm' | 'lg';
  className?: string;
};

type ButtonDefaultProps = ButtonPropsBase &
  React.ButtonHTMLAttributes<HTMLButtonElement>;

type ButtonProps = AsChildProps<ButtonDefaultProps>;
typescriptconst Button = forwardRef<HTMLButtonElement, ButtonProps>(
  (props, ref) => {
    if (props.asChild) {
      // asChild が true の場合
      return <Slot ref={ref}>{props.children}</Slot>;
    }

    // asChild が false の場合
    const { variant, size, className, ...rest } = props;
    return (
      <button
        ref={ref}
        className={twMerge(
          buttonVariants({ variant, size, className })
        )}
        {...rest}
      />
    );
  }
);

使用時の型チェック

typescript// OK: asChild が false の場合、onClick などが使える
<Button onClick={handleClick} variant="default">
  クリック
</Button>

// OK: asChild が true の場合、children が必須
<Button asChild>
  <a href="/home">ホーム</a>
</Button>

// エラー: asChild が true なのに children がない
<Button asChild />

// エラー: asChild が true なのに onClick を指定
<Button asChild onClick={handleClick}>
  <a href="/home">ホーム</a>
</Button>

カスタム Slot の実装

Radix UI の Slot をカスタマイズして、独自のマージロジックを実装することもできます。

イベントハンドラのカスタムマージ

typescriptimport React from 'react';
import { Slot as RadixSlot } from '@radix-ui/react-slot';

function mergeProps(parentProps: any, childProps: any) {
  const merged = { ...parentProps };

  // すべてのプロパティをループ
  for (const key in childProps) {
    const parentValue = parentProps[key];
    const childValue = childProps[key];

    // イベントハンドラの場合
    if (
      key.startsWith('on') &&
      typeof childValue === 'function'
    ) {
      merged[key] = (...args: any[]) => {
        // 子を先に実行
        childValue?.(...args);
        // 親を後に実行
        parentValue?.(...args);
      };
    }
    // className の場合
    else if (key === 'className') {
      merged[key] = twMerge(parentValue, childValue);
    }
    // style の場合
    else if (key === 'style') {
      merged[key] = { ...parentValue, ...childValue };
    }
    // その他のプロパティは子を優先
    else {
      merged[key] = childValue;
    }
  }

  return merged;
}

このカスタムマージロジックにより、プロパティごとに異なる処理を適用できます。

カスタム Slot コンポーネント

typescriptconst CustomSlot = forwardRef<any, any>(
  ({ children, ...props }, ref) => {
    if (React.isValidElement(children)) {
      const mergedProps = mergeProps(props, children.props);

      return React.cloneElement(children, {
        ...mergedProps,
        ref,
      });
    }

    return null;
  }
);

使用例

typescriptfunction MyButton({ asChild, onClick, ...props }) {
  const Comp = asChild ? CustomSlot : 'button';

  return (
    <Comp
      onClick={(e) => {
        console.log('親の onClick');
        onClick?.(e);
      }}
      {...props}
    />
  );
}

// 使用時
<MyButton
  asChild
  onClick={() => console.log('props の onClick')}
>
  <button onClick={() => console.log('子の onClick')}>
    クリック
  </button>
</MyButton>;

// 出力順序:
// 1. "子の onClick"
// 2. "親の onClick"
// 3. "props の onClick"

ref の適切な転送

複数のコンポーネントが合成される場合、ref の転送にも注意が必要です。

複数 ref のマージ

typescriptfunction mergeRefs<T>(
  ...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
  return (instance: T | null) => {
    refs.forEach((ref) => {
      if (typeof ref === 'function') {
        ref(instance);
      } else if (ref != null) {
        (ref as React.MutableRefObject<T | null>).current =
          instance;
      }
    });
  };
}

Button コンポーネントでの使用

typescriptconst Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ asChild, ...props }, forwardedRef) => {
    const internalRef = useRef<HTMLButtonElement>(null);

    // 内部の ref と外部から渡された ref をマージ
    const mergedRef = mergeRefs(internalRef, forwardedRef);

    const Comp = asChild ? Slot : 'button';

    return <Comp ref={mergedRef} {...props} />;
  }
);

使用例

typescriptfunction Parent() {
  const buttonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    // ref を通じて DOM 要素にアクセス
    buttonRef.current?.focus();
  }, []);

  return (
    <Button ref={buttonRef} asChild>
      <a href='/home'>ホーム</a>
    </Button>
  );
}

この実装により、asChild を使用した場合でも、親コンポーネントから DOM 要素への参照を取得できます。

まとめ

shadcn/ui の asChildSlot パターンは、React コンポーネント設計における重要な進化です。このパターンにより、以下が実現されています。

実現された価値

#項目詳細
1責務の明確な分離ライブラリは振る舞いのみを提供し、見た目は利用者が決定
2DOM の最適化不要なラッパー要素を排除し、セマンティックな HTML を生成
3柔軟な合成複数のコンポーネントの機能を単一の要素に統合可能
4型安全性の向上TypeScript による厳密な型チェックが可能
5保守性の改善明確な API により、コードの理解と拡張が容易

設計原則

asChild / Slot パターンは、以下の設計原則を体現しています。

単一責任の原則: 各コンポーネントは「振る舞い」または「見た目」のいずれか一方に責任を持ちます。

開放閉鎖の原則: 既存のコンポーネントを変更せず、新しい要素への適用が可能です。

依存性逆転の原則: 具体的な HTML 要素に依存せず、抽象的なインターフェースに依存します。

適用のベストプラクティス

このパターンを効果的に活用するためには、以下を意識しましょう。

  1. コンポーネントライブラリの設計時:デフォルトの振る舞いを提供しつつ、asChild で柔軟性を確保する
  2. 利用者としての使用時:DOM 構造を意識し、セマンティックな要素を選択する
  3. 型定義の整備AsChildProps 型を活用し、型安全性を確保する
  4. ref の転送forwardRef と ref のマージを適切に実装する
  5. プロパティのマージtwMerge などを使用し、スタイルの競合を回避する

今後の展望

React のエコシステムでは、asChild / Slot パターンがますます重要になっていくでしょう。このパターンは、コンポーネントの再利用性と柔軟性を高めながら、型安全性とパフォーマンスを維持する優れた方法です。

shadcn/ui や Radix UI の成功は、このパターンの有効性を証明しています。今後、より多くのライブラリがこのアプローチを採用し、React コンポーネント設計の標準的な手法になっていくことが期待されます。

関連リンク