React、TypeScript、CVA、TailwindCSSを使ってポリモーフィックコンポーネントなボタンを作成する

Webアプリケーションでは、スタイルが変わらずに異なる振る舞いを持つコンポーネントがよく見られます。

例えば、ボタンには基本的に`<button>`要素を利用して`onClick`を使用しますが、`<a>`要素として`href`も持つことがあります。

その振る舞いがどうあれ、ボタンコンポーネントはデザインシステムのスタイルを保持すべきです。ここで「ポリモーフィックコンポーネント」と呼ばれるものが登場します。

この記事では、同じスタイルを保ちつつ振る舞いを変えることができるポリモーフィックボタンの作成方法について解説します。

![polymorphic-component.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42432/be5921b9-d783-3727-a785-4d281a2c1981.jpeg)

## 目的

目的は単純で、`html`要素を変更できるボタンコンポーネントを持つことです。

例えば、ボタンは`<a>`リンクや`<button>`ボタンのように振る舞うことができ、したがってそのルート要素の属性を持つことができます。

```home.tsx
import React from 'react'
import { Button } from '@/ui/button'

export const Home = () => {
  return (
    <>
      <Button as={'a'} href={'/test'}>
        内部 リンク
      </Button>
      <Button
        as={'a'}
        href={'https://www.example.com'}
        rel="noreferrer"
        target="_blank"
      >
        外部 リンク
      </Button>
      <Button>ボタン</Button>
    </>
  )
}
```

似たようなスタイルのコンポーネントがありますが、DOMを調べてみると、ボタンコンポーネントのルートにある要素はまったく異なっています。

![内部リンク・外部リンク・ボタン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42432/01abcc6f-be34-3ffa-f841-0c2dfd89456d.png)

## 利点

ポリモーフィックコンポーネントを作成することには3つの利点があります。

:::note info
- **HTML構造の一貫性**: `<button>`と`<a>`要素が互いに入れ子にならないので、クローラーはWebアプリケーションの構造を正しく解釈します
- **柔軟性**: `<button>`と`<a>`要素に加えて、コンポーネントは、例えば`role="button"`を持つ`div`のような、どんな要素でも取ることができます
- **堅牢性**: `Typescript`のおかげで、入力された要素に応じて、`IDE(例: VSCode)`は期待される属性を正しく解釈します（`<a>`には`href`がありますが、`<button>`にはないため異常性を検知してくれます）
:::

## 1. ボタンコンポーネントの作成

最初のステップは、下記のように作成することです。

```button.tsx
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>

export const Button = ({ ...props }: Props) => {
  return <button {...props} />
}
```

ここで、ボタンコンポーネントは、HTML属性の`Props`に似た属性を期待します。
これは動的ではなく、ルート要素は`<button>`でなければなりません。

## 2. ボタンのスタイルを追加

次のステップは、デザインシステムに合わせてボタンにスタイルを追加することです。

`TailwindCSS`と`tailwind-merge`で、`Class Variance Authority (CVA)`を使用します。
デザインシステムと`Class Variance Authority (CVA)`については、下記の参考文献を参照してください。

**参考文献**
- [Class Variance Authority](https://cva.style/docs)
- [Next.jsでTailwindを使ったデザインシステムの構築をしよう！](https://zenn.dev/kapureka/articles/b53fb14dacfad9)
- [Vue × Tailwind CSS × CVA × tailwind-mergeによるコンポーネント実装](https://zenn.dev/shiori_hoshimi/articles/afad1f1e176a71)
- [Class Variance Authority(CVA) で Tailwind CSS の className を管理する](https://goodpatch-tech.hatenablog.com/entry/tailwind-with-cva)

```button.tsx
import React from 'react'
import { twMerge } from 'tailwind-merge'
import { cva, type VariantProps } from 'class-variance-authority'

// スタイルを追加
const button = cva(['font-medium', 'py-2.5', 'px-3.5', 'rounded-md'], {
  variants: {
    intent: {
      primary: ['bg-blue-500', 'text-white'],
      secondary: ['bg-black', 'text-white'],
      ghost: ['text-blue-500'],
    },
  },
  defaultVariants: {
    intent: 'primary',
  },
})

type Props = React.ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof button>

export const Button = ({ className, intent, ...props }: Props) => (
  <button className={twMerge(button({ className, intent }))} {...props} />
)
```

これで、ボタンコンポーネントは`Props`でインテントを受け取ることができるようになりました。あとはボタンのポリモーフィックな側面を管理するだけです。

## 3. as属性を変更して要素をパラメータとして受け取る

ここで、コンポーネントのルートで使用したい要素を指定できるようにする必要があります。

慣例では、`as={要素(エレメント)}`属性を使用します。
したがって、特定のHTML要素にデフォルトで存在する`as`を削除し、Elementタイプを待つ必要があります。

```button.tsx
import React from 'react'
import { twMerge } from 'tailwind-merge'
import { cva, type VariantProps } from 'class-variance-authority'

// スタイルを追加
const button = cva(['font-medium', 'py-2.5', 'px-3.5', 'rounded-md'], {
  variants: {
    intent: {
      primary: ['bg-blue-500', 'text-white'],
      secondary: ['bg-black', 'text-white'],
      ghost: ['text-blue-500'],
    },
  },
  defaultVariants: {
    intent: 'primary',
  },
})

// デフォルトを削除
type Props = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'as'> & {
  as?: Element
} & VariantProps<typeof button>

export const Button = ({ className, intent, ...props }: Props) => (
  <button className={twMerge(button({ className, intent }))} {...props} />
)
```

## 4. 動的型の使用

最後に、ルート要素を動的に変更し、各要素に期待される型を高度に解釈することです。

デフォルトでは、私たちのコンポーネントはボタンなので、コンポーネントがインスタンス化されるときに `as`が指定されなければ、この要素を保持します。

```button.tsx
import React from 'react'
import { twMerge } from 'tailwind-merge'
import { cva, type VariantProps } from 'class-variance-authority'

// デフォルトを削除することにより、要素に応じて動的に型を取得
type PolymorphicProps<Element extends React.ElementType, Props> = Props &
  Omit<React.ComponentProps<Element>, 'as'> & {
    as?: Element
  }

// スタイルを追加
const button = cva(['font-medium', 'py-2.5', 'px-3.5', 'rounded-md'], {
  variants: {
    intent: {
      primary: ['bg-blue-500', 'text-white'],
      secondary: ['bg-black', 'text-white'],
      ghost: ['text-blue-500'],
    },
  },
  defaultVariants: {
    intent: 'primary',
  },
})

// バリアント...異なるデータ型を単一の型として扱うことができるデータ構造
// Class Variance Authority (CVA)を利用してバリアントを取得する方法
type Props = VariantProps<typeof button>

// asが指定されていない場合のデフォルト要素を定義
const defaultElement = 'button'

// 適切に型付けされ、デザインされたButtonコンポーネント
export const Button = <
  Element extends React.ElementType = typeof defaultElement
>(
  props: PolymorphicProps<Element, Props>
) => {
  const { as: Component = defaultElement, className, intent, ...rest } = props
  return (
    <Component className={twMerge(button({ className, intent }))} {...rest} />
  )
}
```

https://github.com/andmorefine/react-ts-polymorphic-components

## まとめ

`TailwindCSS`を使用して、`React(Typescript)`でポリモーフィックコンポーネントなボタンを作成することは、スタイルの一貫性とHTML要素の選択の柔軟性を維持するための、綺麗な解決を提供します。

このアプローチにより、HTML構造はクリーンでクローラーに理解しやすいままとなり、アクセシビリティと `UI/UX`が向上します。

さらに、`Typescript`を使用することで、`React`で`Typescript`を使用する多くの利点の恩恵を受けることができます。

ポリモーフィックなボタンと`Class Variance Authority (CVA)`によるスタイリングアプローチの組み合わせにより、保守性や再利用性が高く、柔軟で、拡張性が高いコンポーネントといえます。