ReactプロダクトにおけるButtonコンポーネント実装の最適解を探し続けた結果
ここ1ヶ月くらい、時間を見つけては、いろいろなReactのUIライブラリの実装を読み、
- いかに少ない実装で
- 依存ライブラリなしで
- 最大限の汎用性を実現するか(aタグでもnext/linkでも、任意の要素として振る舞いたい)
を探し続けた。その結果を残す。
(あと、👇こんな宣言をした手前、早めにまとめたいというお気持ち)
アジェンダ
現時点での準最適解
実装コードの変更履歴
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)
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
(実装コードとテストはこちらで試せる)
型定義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で使われている。
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タグ特有の引数は渡せないようになっている。
// ❌
<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
である。
import { cloneElement } from 'react'
cloneElement(<button />, { className: "..." })
// -> <button className="..."></button>
以下に、いくつか覚えておきたい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にできる方法は、おそらくこれが一番シンプル。
準最適解の実装解説、お次はこちら
{
"data-variant": variant,
className: clsx(
styles.button,
shouldActAsChild && children.props.className,
"className" in buttonProps && buttonProps.className
),
},
おさらいになるが、cloneElementの第2引数で「第1引数の要素に渡す属性」を指定できる。まずは"data-variant": variant,
の部分。これはvariant
引数の値に応じてスタイルを切り替えるためのもの
.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 className="override"></Button>
// -> <button class="button override" ...
実装の解説も残りわずか。
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タグはおけない。
つまり、asChild
をつけ忘れるだけで仕様違反になってしまう(この場合、ユーザー目線でどのような弊害がでるかは後の章「なぜ、プロダクトにおけるButtonコンポーネントで、任意のタグとして振る舞える必要があるのか」で解説している)。
コードレビューでも目視だと見逃す可能性がある。これを取りしまるためには独自のEslintルールを書く必要がある認識
弱点2:初見で動作が想像できない
cloneElement
はUIライブラリではそこそこ使われるものの、プロダクトコードではほとんど見かけないはず。初見では動作が想像つかないので、JSDocを書いたり、コメントでこの記事のリンクを残すなどが必要。
弱点3:パフォーマンス
cloneElementしている分、通常のbuttonタグのレンダリングよりもコストがかかる認識
しかし、現時点でhappy-dom環境でベンチマークをとると、ほぼほぼ差はなく、たまに準最適解のコードのほうが、cloneElementを使っていないButtonコンポーネントよりも早かったりする🤔
弱点4:子要素の型
本来はclassName
を受け取れる要素だけが子要素に来てほしいが、以下のように、classNameを受け取らない要素をおいても型エラーが出てくれない。
うまい解決策を探し中。
採用にいたらなかった他の実装パターンたち
まずは、いろいろなUIライブラリで使われているパターンを見ていく。
ちなみに、ReactコンポーネントでレンダリングされるHTML要素の種類を変更可能にするためのパターン
この記事が非常に勉強になる。まずは一読していただくのをおすすめする。
as
パターン
<Button as="a" href="...">リンク</Button>
多くのReact UIライブラリで採用されている。ライブラリによってはas
ではなくcomponent
という引数だったりする
-
as
-
compenent
デメリットとしては、この章の冒頭で紹介した記事に書かれていることに加えて、
- 型パズルが必要で実装が複雑になる(これとかこれとかライブラリを使えば実装は楽になる。ただし、依存ライブラリを増やさなくても良いのならそれが一番)
- Next.jsのTyped Linkなど、
as
引数にジェネリクスを使ったコンポーネントを渡すと推論がうまく効かなくなる(参考:Mantineでの回避策)
そうじて、デメリットが目立つので採用には至らず。
render
パターン
<Button render={<a href="..." />}>リンク</Button>
AriaKitやMantineの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だけど実態はリンク」というのは多くのプロジェクトで発生する。以下の考察が非常に勉強になる。
以上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として振る舞いたいリンク以外のコンポーネントが出てくることも考えられる
<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>
`);
});