asChild
prop とは
コンポーネントを使うときに表示される要素を変えたい!
汎用的なコンポーネントを使用する際、以下のような場合にデザインや挙動は変えずに中でレンダリングされる HTML 要素だけ動的に変えたくなることがあります。
- ボタンコンポーネントでリンク(
<a>
要素、 Next.js や Gatsby などのLink
コンポーネント)を表示したい - タイトルを持ったセクションコンポーネントで
<main>
要素を表示してセマンティックを変えたい - フォームコンポーネントで
<search>
要素を表示してセマンティックを変えたい - 汎用的なヘッドレスコンポーネント(ロジックや挙動のみのコンポーネント)を実装したい
余談: そもそも見た目は変えずに HTML 要素だけ変えるのってアリ?
コンポーネントの見た目を変えずに表示される HTML 要素を変えると、ユーザの違和感や混乱を招く可能性に注意する必要があります。
<a>
要素と <button>
要素ではスクリーンリーダーで読み上げられる内容、右クリック、ホバーなどのアクションの挙動が異なるため、同じ見た目でも<button>
要素を表示するボタンと<a>
要素を表示するボタンで差異が発生します。
また、<section>
要素と <main>
要素、<form>
要素と<section>
要素ではランドマークロールが異なり、スクリーンリーダの読み上げや期待される内容が異なります。
詳細については下記参考リンクをご覧ください。
参考
ポリモーフィックコンポーネント (as
/component
prop) と asChild
prop パターン
上記のような場合に表示される HTML 要素を指定するために、styled-components や Chakra UI などでは as
props が、Material UI では component
props が使用されています。両者とも 表示される HTML 要素名やコンポーネントを props に直接渡すことで指定します。
<Button as={Link} href="https://example.com/">
リンクボタン
</Button>
このようなコンポーネントは「ポリモーフィックコンポーネント」と呼ばれることがあります。「ポリモーフィック(polymorphic)」とはオブジェクト指向などの文脈で「多相性」「多態性」と訳されるポリモーフィズム(polymorphism)の形容詞系で、異なる HTML 要素を一つのコンポーネントで扱えることから来ていると考えられます。
しかし、コードの見た目のわかりにくさや型の複雑性などのいくつかの問題から、 Radix UI では提供するコンポーネントを as
prop を使用するポリモーフィックコンポーネントから、 asChild
prop パターンへ移行しました。このいくつかの問題と経緯については、ReactコンポーネントでレンダリングされるHTML要素の種類を変更可能にするためのパターン や Radix UI ドキュメントのページ Composition、Polymorphic に詳細があるため割愛します。asChild
prop を使用すると先ほどのコードは以下のようになります。
<Button asChild>
<Link href="https://example.com/">
リンクボタン
</Link>
</Button>
href
prop が Link
コンポーネントに渡されていることような直観にコードがより即しているようになったことがわかります。
先述の記事: ReactコンポーネントでレンダリングされるHTML要素の種類を変更可能にするためのパターンの「コンポーネントの中の子を規定する場合」セクション にもあるように、asChild
prop を持つコンポーネントでは、ルートのコンポーネントの内側に別のコンポーネントを入れ子にすると実装が複雑になります。よって、 <Button icon={<FaPlus />} >
のように props で React 要素を指定する composition パターンのような実装が難しくなります。コンポーネントの設計によっては大規模な書き換えが必要になる可能性があります。
asChild
prop を実装する
ここでは、先述の記事やドキュメントなどを読んで「なるほど asChild
prop のメリットデメリットはわかったが具体的な実装方法がわからん」という方向けに asChild
prop を含むコンポーネントの型定義と、それを用いたコンポーネントの実装方法について提案します。 Radix UI の Slot コンポーネントを使用して asChild
prop を実装できます。しかし、Radix UI の Slot コンポーネントのドキュメントには asChild
の型などの詳細な実装方法が記載されていません。
まず asChild
prop 型がどのような振る舞いをして欲しいかを考えます。コンポーネントに asChild
prop が指定されていないときは、 props はベースとなる HTML 要素の属性もマージされ、asChild
prop が指定されていない時はベースとなる HTML 要素の属性は指定できないようにすると便利でしょう。
例えば、ボタンコンポーネントで <a>
タグを表示する例では以下のようになります。
// (asChild が)ない時 😫
<Button
// button 要素が持つ属性は指定できる
type="button"
// @ts-expect-error button 要素にない属性を指定できない(コンパイルエラー)
href="https://example.com/"
>
ボタン
</Button>
// (asChild が)ある時 😆
<Button
// @ts-expect-error button 要素が持つ属性は指定できない(コンパイルエラー)
type="button"
asChild
>
<a href="https://example.com/">リンクボタン</a>
</Button>
この挙動を実現するために、以下のような PropsWithAsChild
というユーティリティ型を作成します。
import type { SlotProps } from "@radix-ui/react-slot";
import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
export type PropsWithAsChild<
// コンポーネント独自の props
Props,
// asChild がない時の HTML 要素 or コンポーネント
DefaultElement extends ElementType
> =
| (// asChild が指定なし or false の時
// DelautElement の ref 以外の props が指定できる
ComponentPropsWithoutRef<DefaultElement> &
Props & {
asChild?: false;
})
| (// asChild が true の時
// Slot の Props が指定できる
SlotProps &
Props & {
asChild: true;
// asChild が true の時は children を必須にしておく
children: ReactNode;
});
この PropsWithAsChild
型を用いて Button
コンポーネントを以下のように作成します。
import { Slot } from "@radix-ui/react-slot";
import { type FC } from "react";
import { PropsWithAsChild } from "@/utils/PropsWithAsChild"
type Props = {
// variant: "small" | "medium" | "large";
// etc.
};
export const Button: FC<PropsWithAsChild<Props, "button">> = ({
// variant,
// etc.
asChild = false,
...props
}) => {
const Component = asChild ? Slot : "button";
return (
<Component
// className=...
// etc.
{...props}
/>
);
};
これで、期待された通りに型がついた asChild
prop を持ったコンポーネントができました。
TypeScript Playground で型の挙動を試すことができます。
まとめ
この記事では、asChild
を含むコンポーネントの型定義と、それを用いたコンポーネントの実装方法について述べました。asChild
prop パターンはまだ新しいアプローチであまり情報がありません。この記事が実装の手がかりになると幸いです。