React コンポーネントの表示する HTML 要素を変える asChild prop を実装する




## `asChild` prop とは

### コンポーネントを使うときに表示される要素を変えたい！

汎用的なコンポーネントを使用する際、以下のような場合にデザインや挙動は変えずに中でレンダリングされる HTML 要素だけ動的に変えたくなることがあります。

- ボタンコンポーネントでリンク（`<a>` 要素、 Next.js や Gatsby などの `Link` コンポーネント）を表示したい
- タイトルを持ったセクションコンポーネントで `<main>` 要素を表示してセマンティックを変えたい
- フォームコンポーネントで `<search>` 要素を表示してセマンティックを変えたい
- 汎用的なヘッドレスコンポーネント（ロジックや挙動のみのコンポーネント）を実装したい


:::note  
余談: そもそも見た目は変えずに HTML 要素だけ変えるのってアリ？

コンポーネントの見た目を変えずに表示される HTML 要素を変えると、ユーザの違和感や混乱を招く可能性に注意する必要があります。

`<a>` 要素と `<button>` 要素ではスクリーンリーダーで読み上げられる内容、右クリック、ホバーなどのアクションの挙動が異なるため、同じ見た目でも`<button>`要素を表示するボタンと`<a>`要素を表示するボタンで差異が発生します。

また、`<section>` 要素と `<main>` 要素、`<form>` 要素と`<section>` 要素ではランドマークロールが異なり、スクリーンリーダの読み上げや期待される内容が異なります。

詳細については下記参考リンクをご覧ください。

##### 参考
- [リンクとボタンを｢押せる｣だけでデザインしない](https://zenn.dev/yusukehirao/articles/4d978c4d9ea788) 
- [ボタン、リンク | UXライティングガイド | upwrite](https://upwrite.jp/writing_uxes/)
- [ウェブアクセシビリティの基本：ランドマークロールを理解する - to-R Media](https://www.to-r.net/media/landmark/)

:::

### ポリモーフィックコンポーネント (`as`/`component` prop) と `asChild` prop パターン

上記のような場合に表示される HTML 要素を指定するために、[styled-components](https://styled-components.com/docs/basics) や [Chakra UI](https://chakra-ui.com/community/recipes/as-prop) などでは `as` props が、[Material UI では `component` props が](https://mui.com/material-ui/guides/composition/)使用されています。両者とも 表示される HTML 要素名やコンポーネントを props に直接渡すことで指定します。

```tsx
<Button as={Link} href="https://example.com/">
  リンクボタン
</Button>
```

このようなコンポーネントは「ポリモーフィックコンポーネント」と呼ばれることがあります。「ポリモーフィック（polymorphic）」とはオブジェクト指向などの文脈で「多相性」「多態性」と訳されるポリモーフィズム（polymorphism）の形容詞系で、異なる HTML 要素を一つのコンポーネントで扱えることから来ていると考えられます。


しかし、コードの見た目のわかりにくさや型の複雑性などのいくつかの問題から、 Radix UI では提供するコンポーネントを `as` prop を使用するポリモーフィックコンポーネントから、 `asChild` prop パターンへ移行しました。このいくつかの問題と経緯については、[ReactコンポーネントでレンダリングされるHTML要素の種類を変更可能にするためのパターン](https://yuheiy.com/2023-06-03-react-changeable-element-type-patterns) や Radix UI ドキュメントのページ [Composition](https://www.radix-ui.com/docs/primitives/guides/composition#composing-multiple-primitives)、[Polymorphic](https://www.radix-ui.com/docs/primitives/utilities/polymorphic) に詳細があるため割愛します。`asChild` prop を使用すると先ほどのコードは以下のようになります。

```tsx
<Button asChild>
  <Link href="https://example.com/">
    リンクボタン
  </Link>
</Button>
```

`href` prop が `Link` コンポーネントに渡されていることような直観にコードがより即しているようになったことがわかります。

:::note
先述の記事: [ReactコンポーネントでレンダリングされるHTML要素の種類を変更可能にするためのパターンの「コンポーネントの中の子を規定する場合」セクション](https://yuheiy.com/2023-06-03-react-changeable-element-type-patterns#%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E3%81%AE%E4%B8%AD%E3%81%AE%E5%AD%90%E3%82%92%E8%A6%8F%E5%AE%9A%E3%81%99%E3%82%8B%E5%A0%B4%E5%90%88) にもあるように、`asChild` prop を持つコンポーネントでは、ルートのコンポーネントの内側に別のコンポーネントを入れ子にすると実装が複雑になります。よって、 `<Button icon={<FaPlus />} >` のように props で React 要素を指定する composition パターンのような実装が難しくなります。コンポーネントの設計によっては大規模な書き換えが必要になる可能性があります。
:::

## `asChild` prop を実装する

ここでは、先述の記事やドキュメントなどを読んで「なるほど `asChild` prop のメリットデメリットはわかったが具体的な実装方法がわからん」という方向けに `asChild` prop を含むコンポーネントの型定義と、それを用いたコンポーネントの実装方法について提案します。 Radix UI の Slot コンポーネントを使用して `asChild` prop を実装できます。しかし、[Radix UI の Slot コンポーネントのドキュメント](https://www.radix-ui.com/docs/primitives/utilities/slot)には `asChild` の型などの詳細な実装方法が記載されていません。



まず `asChild` prop 型がどのような振る舞いをして欲しいかを考えます。コンポーネントに `asChild` prop が指定されていないときは、 props はベースとなる HTML 要素の属性もマージされ、`asChild` prop が指定されていない時はベースとなる HTML 要素の属性は指定できないようにすると便利でしょう。

例えば、ボタンコンポーネントで `<a>` タグを表示する例では以下のようになります。

```tsx
// (asChild が)ない時 😫
<Button
  // button 要素が持つ属性は指定できる
  type="button"
  // @ts-expect-error button 要素にない属性を指定できない（コンパイルエラー）
  href="https://example.com/"
>
  ボタン
</Button>
```



```tsx
// (asChild が)ある時 😆
<Button
  // @ts-expect-error button 要素が持つ属性は指定できない（コンパイルエラー）
  type="button"
  asChild
>
  <a href="https://example.com/">リンクボタン</a>
</Button>
```

この挙動を実現するために、以下のような `PropsWithAsChild` というユーティリティ型を作成します。

```utils/PropsWithAsChild.ts
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` コンポーネントを以下のように作成します。

```Button.tsx
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](https://www.typescriptlang.org/play?target=99&jsx=4#code/PTAEANIVwFwSwDYGdgAUBOB7ADkg6nDABYCCSAwkYgCYB0MSAsAFBwC22m6MoMAntgCmoAN6gAygkwwMOJKAC+oAGZY2oAEQABdAENqcAB4BaKHGDpBugMYxjSKTA0BuFu07deA4WPKYOmAB2goEyWLgExJiwAEqCygA0oACiCIJsITAAKt5JcTYwAHKY1MJKqv6algUuLCyChh48-EKgshGEpBRUCNQAPCygoCCggM0MgM8MgLsMgD8MgLMM44ATDIA3ToBXgYB2DKDY4UzMQ+1ICYPDYLrdNKCAMgyAVgyAIgyAQmYbABJZALIAMqCAgZGABL5HACLxXRQBAwVLpTKgBowELUeRgjKhHJCFgAPlAAF4jgAfUAAChGp0o5wugHBjQBZ2ldAOoMoC4Kl0yGEazuRyGIwBCCBoLSCJ4G0sylAgFO5QBomhstnJLuTAOYMgFkGQDRDCzQH4AsFQvtIkRojA4so+gDlECQfDMmiAGSKvbbUCm0QWoagQk9agAfgAXHSGa5dvahgoAJTYvEEs69S68dBQRnM72ssCSaSgDb7SVk2UKmMSRzJ80Zy0Sm0iO1DR00d0wCOCL0+n3WJ2WQLu-K2YqlKvV-1ekaQcB1ZhdyAAIVgMCC9CQhjcAU8YnjPHKak0On0RlM5mqtnsjlqrCnPDELWEADFyIoVAuNOunF6WCN3Fw921thqyETQ-PKtpgLBECh1Z0X06Gi9gej4SuitreiMABuujoHAuihO6GhIGw9IIBooA4hoGQGFAbAYVhHLoAA5oI26xpCMDWLQLAKNezANE0oDWEESA8EOMAjg2oDHn0f7EABNB8dsSQaAARsOQQaCiaLgbihaQWAMFwQhMCHIplHUUcJahuBBoMupQy0MZ4q4LRfoYmiClDCxgRsUq-icKqPDgTp1CgM6mYJkhEmcVJ9FDJYMBQOggR4oqfTKk5mR2iM1gckgSCFLoGTosZNG5scmkZdWojpaZSAKIqwAokcfpenRN5gN2va9iMuJuZcfq3HcoCALwbgDUeywtn2boGKgH0HFcUcIy+VxXzfBcgCAxoAJgyAHo6gDkBoA9gzSvKRwHui4mSYEQEaVoDDGIxggboI6BYOgoBjUEE2ANYMtyLYASQyrbcgAQ-xMgCLDIAJQyANcMgAVDIAlwzTIAkP9HEQ-KbUQnG4K6IANCl2BpLQLFsMAu2ld6gA7DIA-QzjCwfTAENQTo7VfZgA1IbuRcfqAEIMcqtW1gBge91rE8GJ-WDdtI1gPtSCHY0x12Kd50OhToCPTNgCKDHKgAWDIAgAxXWFPzTfNy3PTcb3jF9f2AyD3obVtfk7dpFOokcfR9WD8QQ1DSAw8AcMcIjyOoyigBXDOMgD1DNj4z47o6P44TgTo0AA) で型の挙動を試すことができます。

## まとめ

この記事では、`asChild` を含むコンポーネントの型定義と、それを用いたコンポーネントの実装方法について述べました。`asChild` prop パターンはまだ新しいアプローチであまり情報がありません。この記事が実装の手がかりになると幸いです。