Closed14

ReactプロダクトにおけるButtonコンポーネント実装の最適解を探し続けた結果

きよしろーきよしろー

ここ1ヶ月くらい、時間を見つけては、いろいろなReactのUIライブラリの実装を読み、

  • いかに少ない実装で
  • 依存ライブラリなしで
  • 最大限の汎用性を実現するか(aタグでもnext/linkでも、任意の要素として振る舞いたい)

を探し続けた。その結果を残す。
(あと、👇こんな宣言をした手前、早めにまとめたいというお気持ち)

https://x.com/kiyoshiro944/status/1732001815959597512?s=20

アジェンダ

きよしろーきよしろー

現時点での準最適解

実装コードの変更履歴

2023/12/13

  • aria-disabledの付け方を改良

2023/12/11

  • タイポ修正

2023/12/08

  • next/linkのhrefにundefinedを渡すとエラーがでるため、disabledにする方法を修正
  • <Button asChild ref={}>とrefを指定できてしまっていたのを修正
  • セミコロンをつけないように

2023/12/07
タイポ修正(priamry -> primary)

Buttonコンポーネントの実装
import { cloneElement, forwardRef, isValidElement } from "react"
import styles from "./style.module.css"
import clsx from "clsx"

export type ButtonProps = {
  variant?: "primary" | "secondary"
  disabled?: boolean
  leftIcon?: React.ReactElement
  rightIcon?: React.ReactElement
} & (
  | { asChild: true; children: React.ReactElement }
  | ({ asChild?: false } & React.ComponentPropsWithRef<"button">)
)

const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = "primary", disabled, leftIcon, rightIcon, asChild = false, children, ...buttonProps },
  ref
) {
  const shouldActAsChild = asChild && isValidElement(children)

  return cloneElement(
    shouldActAsChild ? (
      disabled ? <div aria-disabled /> : children
    ) : (
      <button ref={ref} type="button" disabled={disabled} {...buttonProps} />
    ),
    {
      "data-variant": variant,
      className: clsx(
        styles.button,
        shouldActAsChild && children.props.className,
        "className" in buttonProps && buttonProps.className
      ),
    },
    leftIcon ? <span className={styles.leftIcon}>{rightIcon}</span> : null,
    shouldActAsChild ? children.props.children : children,
    rightIcon ? <span className={styles.rightIcon}>{rightIcon}</span> : null
  );
}) as React.FC<ButtonProps>

export default Button

(実装コードとテストはこちらで試せる)
https://stackblitz.com/edit/vitest-dev-vitest-c1h7rj?file=src%2Fbutton.tsx,src%2Fbutton.test.tsx

型定義10行弱、実装20行くらい。以下のような使い方をする

// 普通にbuttonタグとして使う
<Button variant="primary">普通のボタン</Button>

// aタグとして振る舞う
<Button variant="primary" asChild>
  <a href="...">...</a>
</Button>
// -> <a href="...">...</a>とレンダリングされる(buttonタグはレンダリングされない)

// next/linkとして振る舞う
import Link from 'next/link'

<Button variant="primary" asChild>
  <Link href="...">...</Link>
</Button>

短い実装ながら、汎用性が高く、どんな要素としても振る舞える(正確には、classNameを受け取れる要素に限る)。

ちなみに、イチオシ挙動は以下。

<Button disabled asChild>
  <a href="...">link</a>
</Button>
// -> <div aria-disabled="true">link</div>のように描画されdisabledな表示になる
きよしろーきよしろー

APIの解説

asChildについて

このasChildというパターンは、ヘッドレスコンポーネントとして有名なradix-uiで使われている。
https://www.radix-ui.com/primitives/docs/guides/composition

asChildパターンの実装としてググると@radix-ui/react-slotを使った以下のような実装方法がでてくる。

import { Slot } from '@radix-ui/react-slot';

function Button({ asChild, ...props }) {
  const Comp = asChild ? Slot : 'button';
  return <Comp {...props} />;
}

サイズも1kB弱(minify+gzip)と十分小さいため、依存を増やすことに抵抗がなければ採用しても問題ないとは思う。
自分は依存が増えることによる将来的な保守性の増加を懸念し、採用しなかった。

きよしろーきよしろー

型定義について解説

export type ButtonProps = {
  variant?: "priamry" | "secondary";
  disabled?: boolean;
  leftIcon?: React.ReactElement;
  rightIcon?: React.ReactElement;
} & (
  | { asChild: true; children: React.ReactElement } // 👈<Button asChild><a href"...のように使う
  | ({ asChild?: false } & React.ComponentPropsWithoutRef<"button">) // 👈<Button>普通のボタン</Button>のように使う
);

(exportするかは好みによる。)

まずはこの部分

{
  variant?: "priamry" | "secondary";
  disabled?: boolean;
  leftIcon?: React.ReactElement;
  rightIcon?: React.ReactElement;
}

これはButtonコンポーネントの引数。この引数に応じてUIが変わる。引数の数・種類はプロダクトのデザインに応じて増減する。適宜対応していただきたい。

次はこちら

| { asChild: true; children: React.ReactElement }

children: React.ReactElementに違和感をおぼえるかもしれない。一般的にはchlidren?: React.ReactNode | undefinedだが、あえてこうしている。asChild=trueのとき、<Button>の子要素には必ずJSXの要素1つが来てほしい。

// ✅
<Button asChild>
  <a href="...">...</a>
</Button>

// ❌ 子要素が複数
<Button asChild>
  <a href="...">...</a>
  <a href="...">...</a>
</Button>

// ❌ 子要素に文字列がくる
<Button asChild>
  foo bar
</Button>

// ❌ 子要素が空
<Button asChild></Button>

上記の要望を型レベルでバリデーションできるようにした結果が、children: React.ReactElementである。
また、asChild=trueにした場合は、buttonタグは描画されないため、buttonタグ特有の引数は渡せないようになっている。

type="button"などは渡せない
// ❌ 
<Button asChild type="button">
  <a href="...">...</a>
</Button>

型定義の解説、最後はこちら

  | ({ asChild?: false } & React.ComponentPropsWithRef<"button">)

asChildを省略した、もしくはasChild=falseと指定した場合、buttonタグとして振る舞うので、buttonタグの任意の引数を受け取れるようにしている

任意の引数が受け取れる
<Button aria-label="hoge" type="submit" ...>

(ちなみに、なぜComponentPropsWithoutRefを使わないのかは実装解説の最後で解説する)

きよしろーきよしろー

実装の解説

準最適解の実装部分を解説していく

冒頭の関数定義
const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = "primary", disabled, leftIcon, rightIcon, asChild = false, children, ...buttonProps },
  ref
) {

focusの制御などをしたくなった場合に備えて、refを受け取れるようにして損はないので、forwardRefを使う。function Buttonの部分は、displayNameを指定するためである(https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/display-name.md)。関数名を指定したくない場合は以下のようにもできる。好みに応じて変えてよし。

const Button = forwardRef<HTMLButtonElement, ButtonProps>((
  { variant = "primary", disabled, leftIcon, rightIcon, asChild = false, children, ...buttonProps },
  ref
) => {
// ...略...
}

Button.displayName = 'Button' // displayNameの指定

次に関数の中身を見ていく。

関数の中、冒頭数行
const shouldActAsChild = asChild && isValidElement(children);

return cloneElement(
    shouldActAsChild ? (
      disabled ? <div aria-disabled /> : children
    ) : (
      <button ref={ref} type="button" disabled={disabled} {...buttonProps} />
    ),

まずは、馴染みのない人もいると思うのでReact.cloneElementについて解説。

例えば、buttonタグにclassNameを指定したければ、以下のコードがすぐに思いつくはず。

<button className="...">

では、以下のbuttonElement変数にclassNameを指定したくなった場合はどうだろうか。

const buttonElement = <button />
できないパターン
// ❌ Property 'buttonElement' does not exist on type 'JSX.IntrinsicElements'.ts(2339)
<buttonElement className="..."></buttonElement>

// ❌ Property 'className' does not exist on type 'Element'.ts(2339)
buttonElement.className = "..."

さて困った。こんなときに役立つのが、React.cloneElementである。

React.cloneElementを使った解決策
import { cloneElement } from 'react'

cloneElement(<button />, { className: "..." })
// -> <button className="..."></button>

以下に、いくつか覚えておきたいReact.cloneElementの挙動をまとめる

React.cloneElementの挙動
// 1️⃣ もと要素の引数は上書きされる
cloneElement(<button className="aaa" />, { className: "bbb" })
// -> <button className="bbb"></button>

// 2️⃣ 3つめ以降の引数でchildrenも指定できる
cloneElement(<button />, {}, <span>aaa</span>, <span>bbb</span>)
// -> <button><span>aaa</span><span>bbb</span></button>

話を準最適解の実装コードの解説に戻す。

const shouldActAsChild = asChild && isValidElement(children);

return cloneElement(
    shouldActAsChild ? (
      disabled ? <div aria-disabled /> : children
    ) : (
      <button ref={ref} type="button" disabled={disabled} {...buttonProps} />
    ),
  • <Button asChild disabled><a href="...">のようにした場合はdivタグを
  • <Button asChild><a href="...">のようにした場合は子要素のタグを(この場合はaタグ)
  • そうでない場合はbuttonタグをReact.cloneElementの第一引数に渡している。

一見、isValidElement(children)の部分が冗長に見えるかもしれない。Buttonコンポーネントの型定義で{ asChild: true; children: React.ReactElement }と指定しているので、asChildがtrueであればchildrenはReactElementで確定のように思える。しかし、TypeScrtip5.2時点では、childrenの型は以下のようになり、ReactElementで確定しない。

したがって、型ガードの役割をもつisValidElementも呼んでおく必要がある。

<Button asChild disabled>としたとき、子要素ではなく、かわりにdivタグを描画することでdisabledな動作を再現しているのがポイント。子要素にはaタグでもselectタグでも任意の要素を渡せる。そのすべてで共通してdisabledにできる方法は、おそらくこれが一番シンプル。

きよしろーきよしろー

準最適解の実装解説、お次はこちら

準最適解の実装、cloneElementの第2引数
{
      "data-variant": variant,
      className: clsx(
        styles.button,
        shouldActAsChild && children.props.className,
        "className" in buttonProps && buttonProps.className
      ),
},

おさらいになるが、cloneElementの第2引数で「第1引数の要素に渡す属性」を指定できる。まずは"data-variant": variant,の部分。これはvariant引数の値に応じてスタイルを切り替えるためのもの

style.module.cssの例
.button {
  /* variantの値によらず、Buttonコンポーネント共通のスタイル */
  border-radius: ...;
}
.button[data-variant="primary"] {
  /* variant="primary"のときのスタイル */
  color: ...;
}
.button[data-variant="secondary"] {
  /* variant="secondary"のときのスタイル */
  color: ...;
}

次はshouldActAsChild && children.props.className,の部分。これは以下の挙動を実現するためのもの。

実現したい挙動
<Button asChild>
  <a className="link"></a>
</Button>
// -> <a class="button link" ...
//                         👆Buttonコンポーネントのクラスと、aタグに指定したクラスがマージされる

"className" in buttonProps && buttonProps.classNameについても同様にクラスのマージを実現するためのもの。

buttonタグとして振る舞うときのクラスのマージ
<Button className="override"></Button>
// -> <button class="button override" ...
きよしろーきよしろー

実装の解説も残りわずか。

cloneElementの第3引数
leftIcon ? <span className={styles.leftIcon}>{rightIcon}</span> : null,
shouldActAsChild ? children.props.children : children,
rightIcon ? <span className={styles.rightIcon}>{rightIcon}</span> : null

leftIcon, rightIconはボタンの文字列の左右にアイコンを表示するためのもの。あると、アイコンと文字の間の余白を統一できて便利

アイコンを表示する例
import { IoAddCircle } from "react-icons/io5";

<Button leftIcon={<IoAddCircle />}>ボタン</Button>
<Button rightIcon={<IoAddCircle />}>ボタン</Button>

shouldActAsChild ? children.props.children : children,の部分は、

  • <Button asChild><a href="...">リンク</a></Button>と指定した場合→children.props.childrenが適用される(この場合はリンクという文字列)
  • <Button>ボタン</Button>と指定した場合→childrenが適用される(この場合はボタンという文字列)
きよしろーきよしろー

実装の解説もこれが最後。

最後の型アサーション
const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  // 中略
}) as React.FC<ButtonProps>
    👆👆👆👆

forwardRefの返り値をasで上書きしている。これは以下のバリデーションを型レベルで実現するためのもの

実現したい挙動
// ❌型エラーを出して、取り締まる
// asChildが指定されているときはrefを渡せないようにする
<Button asChild ref={/*...*/}>
  <a href="..."></a>
</Button>

// ✅
<Button asChild>
  {/* 👇 描画されるのはこのaタグなので、refを渡せるのはここ */}
  <a ref={/*...*/} href="..."></a>
</Button>

実装の解説おしまい!!おさらいのために、準最適解のコードをもう一度張っておく。

(再掲)準最適解
export type ButtonProps = {
  variant?: "primary" | "secondary"
  disabled?: boolean
  leftIcon?: React.ReactElement
  rightIcon?: React.ReactElement
} & (
  | { asChild: true; children: React.ReactElement } // 👈 `<Button asChild>...</Button>`とつかうときに適用される型定義
  | ({ asChild?: false } & React.ComponentPropsWithRef<"button">) // 👈 `<Button>普通のボタン</Button>`と使うときに適用される型定義
)

//                 👇 フォーカスの制御などのためにrefを受け取れるように`forwardRef`
const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
//                                                 displayNameの指定👆
  { variant = "priamry", disabled, leftIcon, rightIcon, asChild = false, children, ...buttonProps },
  ref
) {
  // `<Button asChild><a>...`と使ったときにtrueになる。この場合はbuttonタグのかわりに子要素のaタグが描画される
  const shouldActAsChild = asChild && isValidElement(children)

  return cloneElement(
    // 第1引数: ベースとなる要素(ここの属性やchildrenが、第2,3引数で上書きされる)
    shouldActAsChild ? (
      disabled ? <div aria-disabled /> : children
    ) : (
      <button ref={ref} type="button" disabled={disabled} {...buttonProps} />
    ),
    // 第2引数: 属性値(ベース要素の属性に付け足される or 上書きする)
    {
      "data-variant": variant,
      className: clsx(
        styles.button,
        shouldActAsChild && children.props.className,
        "className" in buttonProps && buttonProps.className
      ),
    },
    // 第3引数:  chlidren(ベース要素のchildrenとして適用される)
    leftIcon ? <span className={styles.leftIcon}>{rightIcon}</span> : null,
    shouldActAsChild ? children.props.children : children,
    rightIcon ? <span className={styles.rightIcon}>{rightIcon}</span> : null
  )
}) as React.FC<ButtonProps>
// 👆<Button asChild ref={/*...*/}> という不正なrefの指定を防ぐ 
きよしろーきよしろー

準最適解の弱点

弱点1:HTMLの仕様違反を見逃す可能性がある

問題です。5秒以内に以下のButtonコンポーネントを使っているコードから、HTML仕様違反のものを見つけてください。

// 1️⃣
<Button variant="primary">ボタン</Button>
// 2️⃣
<Button variant="primary">
  <a href="https://example.com/a/b">リンク</a>
</Button>
// 3️⃣
<Button asChild variant="primary">
  <a href="https://example.com">リンク</a>
</Button>
正解は

2️⃣

<button><a href="https://example.com/a/b">リンク</a></button>

と描画される。buttonタグの子要素にaタグはおけない。
https://validator.w3.org/nu/#textarea
でチェックすると以下のエラーがでる。

つまり、asChildをつけ忘れるだけで仕様違反になってしまう(この場合、ユーザー目線でどのような弊害がでるかは後の章「なぜ、プロダクトにおけるButtonコンポーネントで、任意のタグとして振る舞える必要があるのか」で解説している)。
コードレビューでも目視だと見逃す可能性がある。これを取りしまるためには独自のEslintルールを書く必要がある認識

弱点2:初見で動作が想像できない

cloneElementはUIライブラリではそこそこ使われるものの、プロダクトコードではほとんど見かけないはず。初見では動作が想像つかないので、JSDocを書いたり、コメントでこの記事のリンクを残すなどが必要。

弱点3:パフォーマンス

cloneElementしている分、通常のbuttonタグのレンダリングよりもコストがかかる認識
しかし、現時点でhappy-dom環境でベンチマークをとると、ほぼほぼ差はなく、たまに準最適解のコードのほうが、cloneElementを使っていないButtonコンポーネントよりも早かったりする🤔
https://stackblitz.com/edit/vitest-dev-vitest-uetnfv?file=button.bench.tsx

弱点4:子要素の型

本来はclassNameを受け取れる要素だけが子要素に来てほしいが、以下のように、classNameを受け取らない要素をおいても型エラーが出てくれない。

うまい解決策を探し中。

きよしろーきよしろー

採用にいたらなかった他の実装パターンたち

まずは、いろいろなUIライブラリで使われているパターンを見ていく。
ちなみに、ReactコンポーネントでレンダリングされるHTML要素の種類を変更可能にするためのパターン
この記事が非常に勉強になる。まずは一読していただくのをおすすめする。

asパターン

<Button as="a" href="...">リンク</Button>

多くのReact UIライブラリで採用されている。ライブラリによってはasではなくcomponentという引数だったりする

デメリットとしては、この章の冒頭で紹介した記事に書かれていることに加えて、

  • 型パズルが必要で実装が複雑になる(これとかこれとかライブラリを使えば実装は楽になる。ただし、依存ライブラリを増やさなくても良いのならそれが一番)
  • Next.jsのTyped Linkなど、as引数にジェネリクスを使ったコンポーネントを渡すと推論がうまく効かなくなる(参考:Mantineでの回避策)

そうじて、デメリットが目立つので採用には至らず。

renderパターン

<Button render={<a href="..." />}>リンク</Button>

AriaKitMantineのrenderRootなどで用いられるパターン。
複雑な型定義も必要なく、実装も楽。asChildと同じく、cloneElementを使う感じ。

`render`パターンで実装したButtonコンポーネント
import { cloneElement, forwardRef, isValidElement } from "react"
import styles from "./style.module.css"
import clsx from "clsx"

export type ButtonProps = {
  variant?: "primary" | "secondary"
  disabled?: boolean
  leftIcon?: React.ReactElement
  rightIcon?: React.ReactElement
} & (
  | { render: React.ReactElement; children?: React.ReactNode }
  | ({ render?: undefined } & React.ComponentPropsWithRef<"button">)
)

const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = "primary", disabled, leftIcon, rightIcon, asChild = false, children, ...buttonProps },
  ref
) {
  const shouldActAsChild = asChild && isValidElement(children)

  return cloneElement(
    render ? (
      disabled ? <div aria-disabled /> : render
    ) : (
      <button ref={ref} type="button" disabled={disabled} {...buttonProps} />
    ),
    {
      "data-variant": variant,
      className: clsx(
        styles.button,
        render && render.props.className,
        "className" in buttonProps && buttonProps.className
      ),
    },
    leftIcon ? <span className={styles.leftIcon}>{leftIcon}</span> : null,
    children,
    rightIcon ? <span className={styles.rightIcon}>{rightIcon}</span> : null
  )
}) as React.FC<ButtonProps>

export default Button

実のところ自分はこのパターンが最善だと思っていた。以下のLintエラーに出会うまでは。

eslint-plugin-jsx-a11yに存在するanchorタグは子要素にテキストを指定するべきというルールに反してしまう。回避策としてはeslintルールをカスタマイズするか、以下の使い方にする必要がありそう。

// renderの中で、子要素も指定する
<Button render={<a href="...">リンク</a>} />

悪くはないのだが、renderにわたす要素が膨れていくと、とたんに見づらくなる

<Button
  render={
  <a href="...">
   {/*
  *
  * 中略
  *
  */}
  </a>
  }
/> {/* 👈おまえ、誰の閉じタグだ🤔?? */}

この点はasChildが見やすさで勝ると思っている。
ただし、これが気にならないのであれば、renderパターンを採用するのも全然あり。

きよしろーきよしろー

つぎに、UIライブラリではほとんど使われないが、プロダクトコードでは有効かもしれない方法を見ていく。以下に紹介する方法で共通するのは「Buttonコンポーネントを作らない」という逆転の発想。

innerパターン

const ButtonInner = (/* ... */) => <span className="...">{children}</span>

<button>
  <ButtonInner variant="primary">ボタン</ButtonInner>
</button>

<a href="...">
  <ButtonInner variant="primary">リンク</ButtonInner>
</a>

Buttonコンポーネントではなく、そのinnerとなる要素をコンポーネント化するパターン。

このパターンも特段悪いとは思わない。実装も非常に楽。しかし、準最適解と比べて、linkのdisabledを扱えないのが難点。また、単体では使えず、他のタグで囲う前提なのも、不完全ぽさが気になる。

classNameを返す関数

<button className={buttonStyle({ variant: "primary" })}>ボタン</button>

<a href="..." className={buttonStyle({ variant: "primary" })}>リンク</a>

cvaというライブラリなどで提唱されているアイデア。これも悪くないのだが、innerパターンと同様、linkのdisabledを扱えない。
ただし、Buttonコンポーネントとしては力不足なものの、input要素の枠線などのスタイルだけを共通化したい場合は、実装も楽で非常に有用。

きよしろーきよしろー

なぜプロダクトで「任意のタグとして振る舞えるButtonコンポーネント」が必要なのか

大前提の1つめとして、先の章でも触れた通り、buttonの子要素にaタグを置くのは仕様違反である。

❌ 仕様違反
<button>
  <a href="...">リンク</a>
</button>


仕様違反だけでなくユーザビリティにも悪影響があり、キーボード操作でフォーカスを移動しているときに、外側のボタンと内側のリンクで2回フォーカスが当たってしまったり、古いバージョンのFirefoxでリンクとして動かなかったり(参考)といった問題が起きる。

大前提の2つめとして「見た目はボタンUIだけど実態はリンク」というのは多くのプロジェクトで発生する。以下の考察が非常に勉強になる。
https://docs.google.com/presentation/d/1rS7BeO1sqbD9-FGRPkst1TCteXkxvPFlfoQh2fWeEus/edit#slide=id.gb1c3d7fc17_0_0

以上2つの大前提より、Buttonコンポーネントは少なくともリンクとしても振る舞える必要がある。
(2023/12/13現在、「react button コンポーネント」でググると、この条件を満たせていない実装が多く出てきてしまう。悲しい。)

そうすると「任意のタグとして振る舞える」というのは、いささかオーバー気味に思われるかもしれない。
ここで忘れないでおきたいのが、リンクの役割を持つコンポーネントは意外と多いということ。

// 1️⃣ 外部リンク
<a href="...">リンク</a>
// 2️⃣ 内部リンク
import Link from 'next/link'
<Link href="...">リンク</Link>

これだけではない。プロジェクトによっては以下のようなリンクも作ると便利。

// 3️⃣ target="_blank"でセキュリティを考慮したリンク
const SecureNewTabAnchor = (props: React.ComponentPropsWithoutRef<'a'>) => (
  <a {...props} target="_blank" rel="noopener noreferrer" />
)

つまり、1️⃣, 2️⃣だけを考慮してButtonコンポーネントを作ると、3️⃣が出てきたときに面倒になる

<Button href="...">内部リンク</Button>
<Button href="..." external>外部リンク</Button>
// このままだと3️⃣に対応できない。。。
// target引数を増やすか。。。
<Button href="..." external target="_blank">外部リンク</Button>

// もしくは`linkType: 'internal' | 'external' | 'new-tab'`みたいな引数を増やす

これで3️⃣まで対応できて、一段落。と思っていたら、以下のようにボタンUIとして振る舞いたいリンク以外のコンポーネントが出てくることも考えられる

ボタンUIとして振る舞いたい
<input type="reset" value="リセットボタン" /> // 4️⃣ フォームのリセットボタン

<input type="radio" /> // 5️⃣ラジオの選択肢がボタンUI

👇そして、6️⃣ GitHubみたいにsummaryタグをボタンUIで使う場合だってありえる。

このように、一口にボタンUIといってもどのタグとして振る舞うかは多様である。これが「Buttonコンポーネントが簡単なようで普通に難しい」とフロントエンジニアを悩ませる要因になっている。

中途半端に汎用的に作った結果、将来的に汎用さが足りずButtonコンポーネントの実装を修正する必要があったらどうなるのかを想像してみてほしい。プロジェクトで数十か所、数百箇所と使われているButtonコンポーネントの実装をいじる心理的な負担がいかに大きいかを。もちろんButtonコンポーネントのテストがしっかり書かれていて、VRTも導入されているのであれば、既存の実装を壊す心配はなくなる。しかし、元の実装を壊さないように考えながら修正する負担はどうしても残る。

筆者の考えとしては「どこまで汎用的につくるか」と考えるから加減がややこしくなるのであり、最初から極限まで汎用的に作ってしまえば悩むことはなくなる。そして、冒頭で紹介したとおり、極限まで汎用的なButtonコンポーネントは少ないコードで簡単に作ることができる。

きよしろーきよしろー

業務で準最適解の実装を使っていて気がついたが、asChildがネストしたときの挙動が怪しい。解決策を模索中

test("nest", () => {
  const { asFragment } = render(
    <Button asChild>
      <Button asChild>
        <Button>hoge</Button>
      </Button>
    </Button>
  );
  expect(asFragment()).toMatchInlineSnapshot(`
    <DocumentFragment>
      <button
        class="button button"
        data-variant="primary"
        type="button"
      />
      <button
        class="button"
        data-variant="primary"
        type="button"
      >
        hoge
      </button>
    </DocumentFragment>
  `);
});
このスクラップは2023/12/14にクローズされました