40
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

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

Last updated at Posted at 2023-06-26

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-componentsChakra 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 ドキュメントのページ CompositionPolymorphic に詳細があるため割愛します。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 というユーティリティ型を作成します。

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 で型の挙動を試すことができます。

まとめ

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

40
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?