🕺🏿

動的タグでも型がつく僕が欲しい最強の Button.tsx コンポーネントをつくる

2022/01/18に公開

最強のオレオレ Button.tsx とは?

Button の UI コンポーネントを作った時、JS イベントなどを使う時は <button>、 href でリンク遷移したいときは <a>みたいに動的にタグを指定したかったので、その時使い勝手の良い component を考えた。

最強の定義は知らないが、仕様はこんな感じ。

  1. 動的にタグを指定できる。
  2. 指定したタグにある属性だけ props を許可する。
  3. <button><a> の許可したタグのみ props で指定できる
  4. (おまけ) "next/link" の Link タグも指定できるようにする

先に結論コード

忙しい人向けに先に結論。3 が意外と難しくて時間がかかってしまった。

3. next/link なし
Button3.tsx
import classnames from 'classnames'
import { createElement, ReactNode } from 'react'

type Tags = 'button' | 'a'
type Props<T extends Tags> = JSX.IntrinsicElements[T] & {
  as?: T
  children?: ReactNode
  className?: string
}

export const Button = <T extends Tags = 'button'>({ as, children, className, ...props }: Props<T>) => {
  return createElement(
    as || 'button',
    {
      ...props,
      className: classnames(`btn`, className),
    },
    children
  )
}
4. next/link あり(要注意)
Button4.tsx
import classnames from 'classnames'
import Link, { LinkProps } from 'next/link'
import { createElement, ReactNode } from 'react'

type Tags = 'button' | 'a'

type Element<T extends Tags | 'Link'> = T extends Tags
  ? JSX.IntrinsicElements[T]
  : LinkProps & JSX.IntrinsicElements['a']

type Props<T extends Tags | 'Link'> = Element<T> & {
  tag?: T | 'Link'
  children?: ReactNode
  className?: string
}

export const Button = <T extends Tags | 'Link'>({ tag, size, color, children, className, ...props }: Props<T>) => {
  const attrs = {
    ...props,
    className: classnames(`btn`, className),
  }
  const tagName = tag === 'Link' ? 'a' : tag || 'button'
  return tag === 'Link' ? (
    <Link href="/" {...props}>
      {createElement(tagName, attrs, children)}
    </Link>
  ) : (
    createElement(tagName, attrs, children)
  )
}

CSS は .btn にスタイルが当たっている想定で書いていますが、 適宜 tailwind や style jsx など好きな方法でスタイルを当ててください。

要件を詳しく

1. 動的にタグを指定できる。

<Button as="a" href="https://poiit.me">ボタン</Button> のように as を props として、動的にタグを渡せるようにしたい。

import { Button } from '~/components/atoms/Button'

<Button className="mt-2" as="a" href="https://zenn.dev">
  ok:ボタンテキスト
</Button>

<Button className="mt-2" as="button" onClick={onClick}>
  ok:ボタンテキスト
</Button>

2. 指定したタグにある属性だけ props を許可する。

<a> タグには href が指定できるが、<button> では指定できないので、使用できない属性の props はランタイムエラーにしたい。もちろん、Button.tsx コンポーネントで props 人力指定などはしたくない。

<Button className="mt-2" as="a" href="https://zenn.dev">
  ok:ボタンテキスト
</Button>

<Button className="mt-2" as="button" href="https://zenn.dev">
  ng: button に href は指定できない
</Button>

3.<button><a> の許可したタグのみ as で指定できる

<button><a>は指定したいが、<div> など許可したくない。 <div> をボタンに見せかけることは、アクセシビリティーの観点などから防ぎたいので、runtime エラーで型でエラーにしたい。

<Button className="mt-2" as="div" onClick={onClick}>
  ng: as に div は指定できない。
</Button>

Button.tsx コンポーネント作成

全てのタグを動的に指定

まずは、単に全てのタグを指定できるようにするのであれば、下記のような方法で ElementType を利用してあげればつくることができる。

Button2.tsx
import classnames from 'classnames'
import { ElementType, ReactNode } from "react";

type Props = {
  as?: ElementType;
  className?: string;
  children?: ReactNode;
};

export const Button = ({ as: Tag = "button", className, children }: Props) => {
  return <Tag className={classnames(`btn` , className)}>{children}</Tag>;
};

この場合は要件 1. 2. をクリアするが、 as="div" で 型によるランタイムエラーが発生しないので、要件3 がクリアしない。

https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAYwDYEMDOaB2KQFM1wBmUEIcA5MuljvuQFCiSxwBKuKC8xpFUHXcgG5G4aPADecAKJJceTDAAqATzC4ANGwEwAchAAmuOAF8iJMgCJ+nGJZH0Ya4wAUSYAgF44E+nDjoAPwAXDJyCsrOIv5UGLq0IXBoMFDAmADm0YgAFsBIBvyYiey2+kYiJg64AB7M8AgQmMlwAEIArjAwjXDeABRS6KFKKOk9cJYARh1dmJZasWjxePO5+YWmoW4QHgCUPQB8Pn5w-DBtUJhwADzDowtLuJ4SC9h4aL0ABhMwmB9w86g4rQdiZ9s9VgVcJgTFcAPS3fYVERAA

generics で型を渡せるようにして、許可したタグのみを as で渡せるように

type Tags = "button" | "a"; で許可したタグのみを generics で型を渡せるようにすることで、他の div などのタグでエラーになるようにした。

<Button<'a'> as="a" href="https://poiit.me"> みたいに型を渡すことができるが、 as="a" と渡すだけで a 以外の属性がある時に推論されるので、<Button as="a" href="https://poiit.me"> のように使うことができる。

<Button<'button'> as="a" href="https://poiit.me"> は as でランタイムエラーになる。

import classnames from "classnames";
import { createElement, ReactNode } from "react";

type Tags = "button" | "a";

type Props<T extends Tags> = JSX.IntrinsicElements[T] & {
  as?: T;
  children?: ReactNode;
  className?: string;
};

export const Button = <T extends Tags = "button">({
  as,
  children,
  className,
  ...props
}: Props<T>) => {
  return createElement(
    as || "button",
    {
      ...props,
      className: classnames(`btn`, className),
    },
    children
  );
};

少し解説をすると、JSX.IntrinsicElements には、react のタグで指定できる props の型が含まれているのでこちらから取り出して使える。
例えば a に渡せる props は JSX.IntrinsicElements['a'] のように取れる。

ほんとは createElement を利用したくないが、

動的コンポーネントの部分でエラーが発生してしまった

ランタイムエラーになってしまった JSX 記述コード
import classnames from "classnames";
import { ReactNode } from "react";

type Tags = "button" | "a";
type Props<T extends Tags> = JSX.IntrinsicElements[T] & {
  as?: T;
  children?: ReactNode;
  className?: string;
};

export const Button = <T extends Tags = "button">({
  as,
  children,
  className,
  ...props
}: Props<T>) => {
  const Tag = as || "button";
  return (
    <Tag className={classnames(`btn`, className)} {...props}>
      {children}
    </Tag>
  );
};

エラーは TS Playground で確認できるので、わかる方いたらコメントで教えていただけると嬉しいです。
https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAYwDYEMDOaB2KQFM1wBmUEIcA5MuljvuQFCiSxwBKuKC8xpFUHXBk2jwA3mwEwAchAAmuOAF8iJMuX6cYDejACeYBQBUUAcwIBeCgCMArjBgRM5OAB8KKBnoNwACiTBoADyGcLgAHjC4mLIExmYAfHCWAFIAygAaAHQAkpgwUMCYaMAIAKJIuHh5aADahgC6cABkcKL0cHDoAPwAXHCG7YgAFsBIsvyYvRKaMvKDVBhStFNo+YUm9Ir09OHM8AiOq3AAQnYOmElwwaERUTH9phbWZ47k8QAU4ugANMOj41Ffgs0Es8L9MhCwP4CIo+n4IAFgvEAJRJRJtDoHIrwOKXdCuNzkWz2V6DfgwGxQC7vQYdYKmRCoRa0cyiYHYPBod4AAysMEw3KBTJBtGRylEEMyUIRaEU8VpHVaCBGYwmW0VVwA9HF5R1kZt6EA

"next/link" も対応

<Link/> は単純に <a> の代わりとして使えずに、ラップする必要があるので, Link の文字列が as として渡ってきた場合に、Link でラップした。
また、<Link/> には as という props が既に存在していて、 as から tag に props 名を変更した。

Button4.tsx
import classnames from 'classnames'
import Link, { LinkProps } from 'next/link'
import { createElement, ReactNode } from 'react'

type Tags = 'button' | 'a'

type Element<T extends Tags | 'Link'> = T extends Tags
  ? JSX.IntrinsicElements[T]
  : LinkProps & JSX.IntrinsicElements['a']

type Props<T extends Tags | 'Link'> = Element<T> & {
  tag?: T | 'Link'
  children?: ReactNode
  className?: string
}

export const Button = <T extends Tags | 'Link'>({ tag, size, color, children, className, ...props }: Props<T>) => {
  const attrs = {
    ...props,
    className: classnames(`btn`, className),
  }
  const tagName = tag === 'Link' ? 'a' : tag || 'button'
  return tag === 'Link' ? (
    <Link href="/" {...props}>
      {createElement(tagName, attrs, children)}
    </Link>
  ) : (
    createElement(tagName, attrs, children)
  )
}

LinkProps には <Link/> に渡せる Props がはってくるので、T extends Tagsfalse の場合に、JSX.IntrinsicElements["a"] と結合した。

type Element<T extends Tags | "Link"> = T extends Tags
  ? JSX.IntrinsicElements[T]
  : LinkProps & JSX.IntrinsicElements["a"];

"next/link" を 一種のタグとして、コンポーネントに含めるべきか。

最後に設計として、そもそも"next/link" を 一種のタグとして、コンポーネントに含めない方がいいのでは?と話になった。

next/link のドキュメントに、下記のようにあるように、 <Link/> を一種のタグとして捉えるよりも <Link/> コンポーネントとして切り分けて使ったほうがいい気もしてきている。

ドキュメントのコード
import Link from "next/link";

function Home() {
  return (
    <ul>
      <li>
        <Link href="/">
          <a>Home</a>
        </Link>
      </li>
      <li>
        <Link href="/about">
          <a>About Us</a>
        </Link>
      </li>
      <li>
        <Link href="/blog/hello-world">
          <a>Blog Post</a>
        </Link>
      </li>
    </ul>
  );
}

export default Home;

つまり、このように使ったほうが良さそう。

<Link href="https://poiit.me">
  <Button as="a">ボタン</Button>
</Link>

また <Link> を含めたコンポーネントでまだ実運用してないので、思わぬ不具合が出た際に対応が複雑化する可能性があるので、なおさら。
ただ個人的には、tree 構造として一種のタグとして指定できたほうが見やすいので、試してみようと思い書いてみた。

この辺りの意見や next が <NuxtLink> のような挙動にしなかった理由がわかりやすい情報があれば教えて欲しいです。


scrap メモ。なんか雑に、わいわいした方はどうぞご自由にお使いください。
https://zenn.dev/yahsan2/scraps/0c905eec4ea19d

GitHubで編集を提案

Discussion