asChild の課題と他の合成パターンの比較
はじめに
Radix UI の普及とともに asChild プロパティが広く採用され、コンポーネントの柔軟な合成が可能になりました。しかし、その利便性の裏で型安全性の欠如という課題が指摘されています。
TypeScript による型チェックが十分に機能しないこと、React.cloneElement への依存による予期しない挙動、そして React Server Components との互換性の問題——これらが主な課題です。
本記事では、asChild の技術的な課題を掘り下げます。React.cloneElement がどのように機能し、なぜ型安全性が損なわれるのかを探りつつ、render prop、as prop、hooks といった代替の合成パターンを比較検討します。Radix UI、Ark UI、Ariakit、Headless UI、Base UI、React Aria がそれぞれ異なる設計思想を持つ背景にも触れながら、整理していきます。
asChild の基礎と問題の本質 🔍
asChild とは何か
asChild は、Radix UI が提供するコンポーネント合成パターンです。親コンポーネントの機能(アクセシビリティ属性、イベントハンドラなど)を、子要素に「引き継がせる」ことで、レンダリングする要素を柔軟にカスタマイズできます。
基本的な使い方
// asChild を使わない場合
<Dialog.Trigger>
<button>開く</button>
</Dialog.Trigger>
// asChild を使う場合 - 任意の要素に機能を移譲
<Dialog.Trigger asChild>
<a href="/dialog">開く</a>
</Dialog.Trigger>
この例では、Dialog.Trigger が持つアクセシビリティ属性やイベントハンドラが、<a> タグに適用されます。button ではなく a タグとしてレンダリングされるため、デザインシステムの柔軟性が向上します。
実装の仕組み:Slot コンポーネント
asChild は、Radix UI の内部で Slot という特殊なコンポーネントによって実装されています[1]。その役割は、親の props を受け取り、それを唯一の子要素と React.cloneElement を使って合成(マージ)することです。
Slot は React.cloneElement を使って、親コンポーネントの props と子要素の props をマージします。この際、ref は composeRefs で合成し、className は結合するなど、単純な上書きではない処理を実行します。これにより、開発者は要素の種類を自由に変更しながら、親の機能を維持できます。
より詳細な実装は、以下のパーマリンクから確認できます。React.cloneElement で props や ref を合成している箇所がポイントです。
-
@radix-ui/react-slotの実装
asChild の基本的な仕組みを理解したところで、次にその技術的な課題を掘り下げます。
型安全性の欠如
最大の問題は、asChild を使うと 子要素に対する TypeScript の型チェックが緩くなることです。通常(asChild を使わない)場合は子要素が ReactElement に限定されるなど型安全性が確保されていますが、asChild を指定すると、一見問題ないコードでも実行時にエラーが発生する可能性があります。
問題のコード例
// ❌ 型エラーが発生しない(実行時エラーの可能性)
<Dialog.Trigger asChild>
{/* フォーカス可能でもボタンロールでもない要素 */}
<div>開く</div>
</Dialog.Trigger>
// ❌ 必須プロパティの欠落も検出されない
<Tooltip.Trigger asChild>
{/* aria-labelが必要だが、型チェックされない */}
<span>Hover me</span>
</Tooltip.Trigger>
// ❌ ref の互換性もチェックされない
<Popover.Trigger asChild>
{/* ref転送が必要だが、型チェックされない */}
<CustomComponent />
</Popover.Trigger>
TypeScript は children の型を ReactNode として扱うため、子要素が親コンポーネントの要求する props を満たしているかを検証できません。これは実行時に突然動かなくなるバグの原因になります。
なぜ型チェックが機能しないのか
開発者が以下のように書いたとします。
<Dialog.Trigger asChild>
<a href="/settings">設定</a>
</Dialog.Trigger>
TypeScript は <a> 要素が持つ href などの具体的な型情報を認識しています。しかし、この <a> 要素が Dialog.Trigger の children として渡されると、TypeScript はその具体的な情報を失い、単に ReactNode という「任意のReact要素」として扱ってしまいます。
親コンポーネントは children の中身が何であるかを知らないまま React.cloneElement で props をマージしようと試みます。ReactNode はあらゆる React の子要素を受け入れる最も緩い型であるため、TypeScript はコンパイル時にその矛盾を検出できず、実行時まで問題が先送りされてしまうのです。
cloneElement への依存による脆弱性
cloneElement への依存は、いくつかの脆弱性を引き起こします。
React Server Components (RSC) との互換性問題
cloneElement の利用は、RSC や React Compiler との組み合わせで複数の課題を抱えています。第一に、cloneElement は動的に要素を操作するため、React Compiler による静的な解析・最適化を阻害する可能性があります。
より深刻なのが、Server Component との互換性です。RSC では、非同期コンポーネントはクライアント側で 「解決待ちの Promise」 として扱われます。つまり、まだ実体(React要素)が存在しない「予定地」のような状態であるため、cloneElement で props を注入しようとしても対象が存在せず失敗します。
かつて、この問題により「asChild 内の非同期コンポーネントが画面から消失する」という重大なバグが存在しました[2]。これに対し、@radix-ui/react-slot@1.2.0(2024年10月)で対策が行われ、children が Promise の場合は createElement にフォールバックすることで要素消失という最悪の事態は回避されました。
しかし、これは根本的な解決ではありません。フォールバック時は props のマージが行われないため、結果としてクリックイベントが発火しないなど、「エラーは出ないが機能しない」問題が依然として残るリスクがあります。
React 19 では、非同期 Server Components を cloneElement で処理すると hydration mismatch や一部環境で undefined になるケースが報告されており[3]、実運用での利用は推奨されなくなっています。
React コアチームの Sebastian Markbåge 氏は以下のように表明しました。
"cloneElement is basically soft deprecated. It works against any ability to optimize by inlining."
cloneElement は「soft deprecated(実質的に非推奨)」とされ、React の最適化戦略と相容れないという公式見解です。
ref の合成(Ref Merge)問題
cloneElement を使う際のもう一つの課題が ref の扱いです。asChild の親コンポーネントは、しばしば子要素の DOM ノードに ref を渡して、位置計算やフォーカス管理を行います。
function Parent() {
const parentRef = React.useRef(null);
// Dialog.Trigger は parentRef を <CustomButton> の内部にある実際の
// DOM要素に渡したいが、CustomButton が forwardRef で正しく転送していないと、
// ref が途中で途切れて機能しない
return (
<Dialog.Trigger asChild ref={parentRef}>
<CustomButton />
</Dialog.Trigger>
);
}
// ❌ Bad: forwardRef を使っていない、または ref をDOMに渡していない
const CustomButton = (props) => {
// この実装では親から渡された ref が失われる
return <button {...props}>Click me</button>;
};
Radix UI は composeRefs というユーティリティで、このような親と子の ref を両方とも正しく DOM 要素に適用できるように共有(合成)します。(composeRefs は Radix が提供する ref を「複数マージする」便利関数です)
しかし、この仕組みは asChild 特有の複雑さです。render prop や hooks では ref を明示的に渡すため、このような合成の問題は発生しません。
propsマージの隠された複雑さ
asChild を使うと親と子の props がマージされますが、その挙動は必ずしも直感的ではありません。
<Dialog.Trigger asChild onClick={() => console.log('親のonClick')}>
<button className="btn-primary" onClick={() => console.log('子のonClick')}>
開く
</button>
</Dialog.Trigger>
// 👇 実際にレンダリングされる HTML(イメージ)
// <button
// class="btn-primary radix-state-open" /* ← クラスが結合される */
// onClick={() => {
// console.log('親のonClick'); /* ← 両方のハンドラが実行される */
// console.log('子のonClick');
// }}
// >
// 開く
// </button>
例えば Radix UI の場合、イベントハンドラは親→子の順で実行され、className は単純結合されます。しかしこれらは Radix の独自実装であり、他のライブラリでは挙動が異なる可能性があります。render prop では props を明示的に展開するため、このような暗黙のルールは存在しません。
代替パターンの探求 🎯
asChild の課題を踏まえ、各ライブラリは異なるアプローチでコンポーネント合成を実現しています。ここでは、主要な代替の合成パターンと、それを採用しているライブラリの設計哲学を紹介します。
render prop パターン
render prop パターンは、render prop または children prop に関数を渡すことで、コンポーネントのレンダリングをカスタマイズする手法です。親コンポーネントから渡される props を直接制御でき、cloneElement を使わないため型安全性が高く、RSC との互換性も良好です。
関数のシグネチャによって TypeScript が props の型を厳密に推論できるため、コンパイル時に不整合を検出できます。Ariakit と Base UI が代表的な採用例で、それぞれ異なるアプローチで実装しています。
Ariakit の実装例
Ariakit は render prop または children prop に渡された関数を実行することで、コンポーネントのレンダリングをカスタマイズします[4]。内部的には render prop がReact要素か関数かを判定し、関数であれば props を引数として実行します。
型安全性の実現:
// Ariakit の型定義(簡略版)
type TriggerProps<T extends ElementType = 'button'> = {
render?: (props: ComponentPropsWithRef<T>) => ReactElement
children?: (props: ComponentPropsWithRef<T>) => ReactElement
}
// TypeScriptが完全に型推論
<Dialog.Trigger
render={(props) => {
// propsの型: ButtonHTMLAttributes<HTMLButtonElement>
props.onClick // ✅ 型推論が機能
props.href // ❌ 型エラー(buttonにhrefはない)
return <button {...props}>開く</button>
}}
/>
階層的アプローチ:
v0.3 のリリースで as と children prop を render prop に統一し[5]、開発者は high-level な抽象化から始めて、より細かい制御が求められる場面で low-level な API に移行できる「階層的アプローチ」を採用しています。
// High-level: シンプルなケース
<Dialog.Trigger>開く</Dialog.Trigger>
// Mid-level: カスタム要素が必要
<Dialog.Trigger render={<Button>開く</Button>} />
// Low-level: 完全な制御
<Dialog.Trigger>
{(props) => (
<Button {...props} onMouseEnter={preload}>
{props.open ? '閉じる' : '開く'}
</Button>
)}
</Dialog.Trigger>
Base UI の実装例
Base UI(@mui/base-ui)は、旧パッケージ(@mui/base)で採用していた slots/slotProps パターン(一部は getRootProps のような Prop Getters パターンに近いものでした)の課題を経て、render prop に完全移行しました[6]。またカスタムコンポーネントとの合成や、data attributes を活用した状態ベースのスタイリングが可能です。
// 基本的な使い方: カスタムコンポーネントの合成
<Menu.Trigger render={<MyButton size="md" />}>
Open menu
</Menu.Trigger>
// data attributes を活用したスタイリング
<Switch.Thumb className="data-[checked]:bg-blue-500 data-[checked]:translate-x-6" />
// 状態に応じた複雑なレンダリング: render prop + Context hooks
<Switch.Thumb
render={(props) => {
const { checked } = useSwitchContext();
return (
<span
{...props}
className={clsx(
'inline-block w-6 h-6 rounded-full transition',
checked ? 'bg-green-500' : 'bg-gray-400'
)}
>
{checked ? 'ON' : 'OFF'}
</span>
);
}}
/>
// デフォルト要素のオーバーライド
<Menu.Item render={<a href="/settings" />}>
Settings
</Menu.Item>
Base UI の render prop は要素または関数を受け入れます。関数形式では、マージ済みの props(data attributes、イベントハンドラ、ARIA 属性など)を引数に取ります。以前の state 引数は廃止され、状態に応じたスタイリングは data attributes を活用するか、複雑な制御には Context hooks を併用する設計です。
Ariakit と異なり、Base UI は render 関数を「Props を DOM に流すこと」に集中させ、状態による見た目の変化は CSS(data attributes)で行うことを推奨しています。この設計により、RSC/SSR との親和性と Tailwind CSS との相性が最適化されています。
利点と欠点
render prop パターンの最大の利点は、関数の引数として props を受け取ることで、型安全性を実現できる点です。コンポーネントの内部状態(open, active など)にもアクセスでき、cloneElement に依存しないため RSC との互換性も確保されます。
一方で、関数で包む必要があるためネストが増加し、インデントが深くなる欠点があります。asChild より直感的でないと感じる開発者もおり、シンプルなケースでも関数を書く必要があるため記述量が増加します。
as prop パターン
Headless UI(Tailwind チーム)が採用する as prop は、レンダリングする要素の型を指定するパターンです[7]。デフォルトの要素を維持しつつ、カスタム要素への変更が必要な場合に柔軟に対応できます。
基本的な実装
// デフォルトは button
<Dialog.Trigger>開く</Dialog.Trigger>
// as prop で要素を変更
<Dialog.Trigger as="a" href="/dialog">
開く
</Dialog.Trigger>
// カスタムコンポーネントも可能
<Dialog.Trigger as={CustomButton}>
開く
</Dialog.Trigger>
型安全性と内部実装
Headless UI は TypeScript のジェネリクス (<T extends ElementType>) を活用して、as prop に渡されたコンポーネントやタグ名から props の型を動的に推論します。実際のレンダリングは内部の render ユーティリティ関数で行われ、この関数は as prop の値(またはデフォルトのタグ)を React.createElement の第一引数に渡して要素を生成します。これにより cloneElement を回避しつつ、動的な要素の切り替えと型安全性を実現しています。
-
@headlessui-reactのrenderユーティリティ
利点と欠点
as prop は、TypeScript のジェネリクスによって props の型を動的に推論できるため、型安全性を保ちながら要素の切り替えが可能です。asChild のようなインデント増加もなく、API がシンプルです。
一方で、重要な制約として Props のマージ動作がない点に注意が必要です。asChild は子要素に既に付与されている className や onClick をライブラリ側の props とマージしますが、as prop は指定したコンポーネントに props を流し込むだけです。カスタムコンポーネントを as={MyButton} とする場合、MyButton 側で ...props と ref を受け取って正しく DOM 要素へ転送する実装が必須となり、Props とref の転送・マージ責任がユーザーコンポーネント側にあることを理解しておく必要があります。また、render prop のように内部状態に直接アクセスできないため、要素変更に特化しており複雑なカスタマイズには不向きです。
v2.0 以降の進化:data attributes の活用
Headless UI v2.0以降、as prop は引き続きサポートされていますが、設計の重心が data attributes ベースのスタイリングへと移行しています。
// v2.0 以降: data attributes を活用
<Dialog.Panel
as="div"
className="data-[open]:opacity-100 data-[closed]:opacity-0 data-[enter]:transition-opacity"
>
内容
</Dialog.Panel>
// Tailwind CSS との組み合わせ
<Menu.Item className="data-[active]:bg-blue-500 data-[focus]:ring-2">
設定
</Menu.Item>
この変更により、以下の利点が生まれました。
- render prop の削減 - 従来
{({ open }) => <div className={open ? 'bg-blue' : 'bg-gray'}>のように書いていたスタイリングを、data-[open]:bg-blueのような CSS だけで実現可能に - RSC 互換性の向上 - Server Component でも data attributes が適用されるため、クライアントサイドの状態管理を減らせる
- Tailwind UI の刷新 - v2.1(2024年6月)で transition prop と
data-[enter/leave/closed]属性が追加され、Tailwind UI(有料コンポーネント集) の全 React コンポーネントから render prop が削除されました。なお、Headless UI 本体では一部レガシーコンポーネント(Transition など)に render prop が残っています
as prop の型安全性とシンプルさを維持しながら、内部状態へのアクセスという欠点を data attributes で補完する、バランス重視の進化に思います。
hooks パターン
React Aria(Adobe)が提供する hooks は、最も低レベルで柔軟なアプローチです[8]。ロジック(状態管理やアクセシビリティ)とビュー(DOM構造やスタイリング)を明確に分離し、開発者に細かな制御を提供します。
基本的な実装
import { useButton } from 'react-aria'
import { useToggleState } from 'react-stately'
function CustomButton(props) {
const ref = useRef<HTMLButtonElement>(null)
const state = useToggleState(props)
const { buttonProps, isPressed } = useButton(props, ref)
return (
<button
{...buttonProps}
ref={ref}
className={isPressed ? 'pressed' : ''}
>
{props.children}
</button>
)
}
// 細かく制御可能
<CustomButton onPress={() => console.log('押された')}>
クリック
</CustomButton>
型安全性と内部実装
React Aria の hooks パターンは、各フックが必要な props を受け取り、DOM要素に適用すべき props のオブジェクトを返す仕組みです。例えば useButton フックは onPress などのイベントハンドラを受け取り、onClick, role, tabIndex といった具体的なDOM属性を含む buttonProps オブジェクトを返します。これにより開発者はUIを自由に構築しながら、アクセシビリティを確保できます。
-
@react-aria/buttonのuseButtonフック
利点と欠点
hooks パターンは、ロジックとビューを明確に分離することで高い柔軟性をもたらします。各 hook が明確な型定義を持つため、組み合わせることで型安全な UI を自由に構築できます。フレームワーク非依存である点も大きな利点です。
一方で、すべてを自分で組み立てる必要があるため実装コストが高く、hooks の組み合わせ方を理解する学習曲線が存在します。チーム全体で使い方を統一する必要があり、一貫性の維持に注意が必要です。
React Aria Components:hooks と render prop の統合
React Aria は、2024年に正式リリースされた React Aria Components (RAC) で、この hooks パターンをさらに発展させました[9]。特に注目すべきは、RSC 互換性の向上と Suspense 強化を含む新しいアプローチです。
RAC は、hooks の複雑さを隠蔽しつつ、Render Prop によるスタイリングと Data Attributes の両方をサポートするハイブリッドアプローチを採用しています。
// React Aria Components の例: Render Prop for Styling
import { Button } from 'react-aria-components'
function App() {
return (
<Button
className={(renderProps) => `
btn
${renderProps.isPressed ? 'btn-pressed' : ''}
${renderProps.isFocusVisible ? 'btn-focus' : ''}
`}
>
クリック
</Button>
)
}
// RSC でも使える
async function SearchPage() {
const suggestions = await fetchSuggestions()
return (
<Autocomplete>
<Input />
<Popover>
{suggestions.map(item => (
<AutocompleteItem key={item.id} href={item.url}>
{item.name}
</AutocompleteItem>
))}
</Popover>
</Autocomplete>
)
}
RAC の className prop は関数を受け入れ、(values) => string の形式で動的なスタイリングを実現します。これは Headless UI v2 の data attributes アプローチ(data-[open]:bg-blue)とは異なり、JavaScript で完結する動的スタイリングの選択肢を提供しています。この設計は、hooks の柔軟性と render prop の型安全性を統合した最新のアプローチとして、コミュニティで注目されています。cloneElement に依存せず、RSC の静的レンダリングとも相性が良好です。
Ark UI:マルチフレームワーク対応の asChild
Ark UI は Chakra UI チームが開発する、Zag.js ベースのマルチフレームワーク対応ヘッドレスライブラリです。React, Vue, Solid, Svelte で動作する点が特徴的で、Radix UI と同様の asChild パターンを採用しています。
設計思想とアプローチ
// 基本的な使い方
<Popover.Trigger asChild>
<button>Open</button>
</Popover.Trigger>
// Context コンポーネントの例
<Popover.Root>
<Popover.Context>
{(api) => (
<Popover.Content>
<Popover.Title onClick={() => api.close()}>Title</Popover.Title>
</Popover.Content>
)}
</Popover.Context>
</Popover.Root>
マルチフレームワーク対応の制約
Ark UI は、現在 asChild を前提とした API を中心に設計されています。(React / Vue / Solid / Svelte など)複数フレームワークで共通のコンポーネント API を提供する都合上、as prop よりも asChild の方が型定義と実装の両面で扱いやすい、という判断が背景にあります。
最近の Ark UI では Context コンポーネントが導入され、asChild パターンとの相性が向上しました。深い階層からも内部 API にアクセス可能になり、より柔軟なコンポーネント合成が実現されています。
パフォーマンスの改善
2023年に cloneElement による再レンダリング最適化の問題が報告されましたが、memoization により解決済みです。ただし、型安全性の課題は Radix UI と同様に残っています。
パターン比較まとめ
各パターンのトレードオフを、本記事の観点から評価し、以下の表にまとめます。
| パターン | 設計思想 | 型安全性 | 柔軟性 | 学習コスト | RSC互換性 | 代表ライブラリ |
|---|---|---|---|---|---|---|
| asChild | DX優先 | △ 弱い | ○ 高い | ◎ 低い | △ 制約あり | Radix UI, Ark UI |
| render prop | 堅牢性・明示性 | ◎ 最強 | ◎ 最高 | ○ 中程度 | ◎ 良好 | Ariakit, Base UI |
| as prop | シンプルさ | ○ 良好 | ○ 中程度 | ◎ 低い | ○ 良好 | Headless UI |
| hooks | 完全な制御 | ◎ 最強 | ◎ 最高 | △ 高い | ◎ 良好 | React Aria |
設計哲学の対立
これらのパターンの選択は技術的な違いだけでなく、開発哲学の違いを反映しています。コミュニティの議論は、コンポーネント設計において何を重視するかによって、大きく2つの思想に分けられます。
cloneElement 許容派(DX・シンプルさ優先) - Radix UI や Ark UI は、プロトタイピングの速度とシンプルな DOM 構造を重視します。asChild がもたらす型安全性の問題は、実行時やテストで検出可能であるという割り切りに基づいています。Headless UI の as prop も、API のシンプルさを保つ点でこの思想に近いと思います。
型安全性優先派(堅牢性・明示性優先) - Ariakit, React Aria, Base UI は、長期的な保守性と予測可能性を最優先します。render prop や hooks パターンを採用することで、コンパイル時に型エラーを検出し、cloneElement が持つ暗黙的な挙動を避けることを目指しています。Base UI が過去の slots/slotProps パターンから render prop へ移行したのが、この思想を象徴しています。
結論と今後の展望 🏁
Radix UI は長らく asChild + Slot(React.cloneElement)を中核的な機能として提供してきましたが、状況は変わっています。React コアチームが cloneElement を「実質的に非推奨(soft deprecated)」と明言したこと[3:1]、そして RSC や React Compiler との互換性課題が顕在化したことを受け、Radix UI コミュニティでも asChild の将来について議論が続いています[10]。
asChild や as prop の簡潔さ、render prop や hooks が提供する堅牢性、それぞれが異なる価値を持っています。特に大規模・長期運用プロジェクトでは、React Aria や Base UI のような、すべてが明示的で型安全性の高いパターンが、実行時エラーのリスクを限りなくゼロに近づける最良の選択肢となると筆者は考えています。(2025年11月現在)
最終的には、各パターンのトレードオフを理解し、プロジェクトの要件に応じた最適な選択を行うことが重要に思います。
以上です!
-
Radix UI Slot 実装(最新 main ブランチ)- GitHub -
radix-ui/primitives/Slot.tsx↩︎ -
Radix UI 公式リポジトリで報告されていた、非同期 Server Component と
asChildの組み合わせで子要素が消失する問題。@radix-ui/react-slot@1.2.0で対応が完了し、2024年10月に Issue は「Closed as completed」としてクローズされた - GitHub Issue #3165 ↩︎ -
GitHub Issue #32392 - cloneElement with Async Server Components - React 19 における
cloneElementと非同期 Server Components の互換性問題が報告されています。React コアチームの Sebastian Markbåge 氏は「cloneElementは soft deprecated(実質的に非推奨)」と表明し、React の最適化戦略と相容れないことを明言しました。 ↩︎ ↩︎ -
Ariakit - Composition Guide(現行最新)- Ariakit - Composition ↩︎
-
Ariakit v0.3 では
asとchildrenprop を render prop に統一(Changelog参照) ↩︎ -
Base UI - Customization API change (RFC) - slots/slotProps から render prop への移行提案。v1.0.0-beta で実装完了 ↩︎
-
Headless UI - Composition(v2 対応)- Headless UI - Menu Composition ↩︎
-
React Aria Components は2023年12月に v1.0 がリリースされ、2024年にかけて正式リリースとなりました。現在も活発な開発が続いています。- React Aria Components 1.0 ↩︎
-
GitHub Issue #2537 - What are the plans for asChild and Slot with React.cloneElement be considered a legacy API method? -
cloneElementが legacy API とされる中でのasChild/Slotの今後についての議論。2024年6月に「現時点では代替手段がないため、asChild/Slotは引き続き機能し実用性があるので維持する」として Closed as not planned となりました。Radix チームは当面この方針を継続しており、完全な脱却は確定していません。 ↩︎
Discussion