Reactの asChild パターンをドロップダウンメニューの実装から理解する
個人開発中のアプリで、自作のドロップダウンメニューにリンクを1つ追加したら、「メニューの行のうち、文字がある部分しかクリックできない」というバグに遭遇しました。原因を追っていくと、Radix UIなど有名なUIライブラリも採用している asChildパターン という設計にたどり着いたので、実装を追いながら整理しておきます。
遭遇したバグ
メニューの1項目として、ユーザー名を表示しつつクリックでプロフィールページに飛べるようにしたくて、最初はこう書きました。
<DropdownMenuItem>
<Link to="/profile">
<span>ユーザー名</span>
</Link>
</DropdownMenuItem>
見た目は問題ないのですが、実際にブラウザで測ってみると次のようになっていました。
| 要素 | 幅 |
|---|---|
DropdownMenuItemが作る行 |
310px |
中のLink(クリックで遷移する範囲) |
72px |
行の見た目は横幅いっぱいなのに、実際にクリックが効くのは文字の幅ぶんだけ。行の余白部分を押すと、メニューは閉じるのに画面遷移はしない、という中途半端な挙動になっていました。
原因:divの中にaを置いていた
自作のDropdownMenuItemは、素直に書くとこういう実装です(抜粋)。
const DropdownMenuItem = ({ children, onClick }: Props) => {
const context = useContext(DropdownMenuContext);
const handleClick = (e: React.MouseEvent) => {
onClick?.(e);
context?.setOpen(false); // クリックでメニューを閉じる
};
return (
<div className="dropdown-menu-item" onClick={handleClick}>
{children}
</div>
);
};
DropdownMenuItemは自分のdivを作り、そこにonClick(メニューを閉じる処理)を付けています。渡したLinkはその中身として描画されるだけなので、DOM構造は次のようになります。
<div class="dropdown-menu-item"> <!-- 行全体。ここを押すとメニューが閉じる -->
<a href="/profile">ユーザー名</a> <!-- ここだけが遷移する -->
</div>
divにはメニューを閉じるonClickがありますが、aタグが持つ「クリックで遷移する」という本来の機能は、aタグ自身の範囲でしか働きません。外側のdivをいくら大きくしても、その恩恵を子のaは受け取れないわけです。
asChildという解決策
欲しかったのは、「行全体がaタグそのものである」という構造でした。
<a href="/profile" class="dropdown-menu-item">ユーザー名</a> <!-- これ1つが行 -->
これを実現するのがasChildという考え方です。「自分(DropdownMenuItem)のdivを作るのではなく、渡された子要素に、自分が持っている振る舞い(onClickなど)だけを注入する」というオプションです。
interface Props {
asChild?: boolean;
children: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
}
const DropdownMenuItem = ({ asChild, children, onClick }: Props) => {
const context = useContext(DropdownMenuContext);
const handleClick = (e: React.MouseEvent) => {
onClick?.(e);
context?.setOpen(false);
};
// asChildがtrueなら、divで包まずに子要素へonClickだけ注入する
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, { onClick: handleClick });
}
return (
<div className="dropdown-menu-item" onClick={handleClick}>
{children}
</div>
);
};
使う側はこうなります。
<DropdownMenuItem asChild>
<Link to="/profile" className="dropdown-menu-item">
ユーザー名
</Link>
</DropdownMenuItem>
asChildがあるときは、DropdownMenuItemは自分のdivを作らず、React.cloneElementで子要素(Link)にonClickだけを追加して、そのまま返します。結果、実際にレンダリングされるのはLinkが持つaタグ1つだけになり、「行全体がa」という構造が手に入ります。
React.cloneElementがやっていること
ここで使われているReact.cloneElement(children, { onClick: handleClick })は、「既存の要素をコピーしつつ、指定したpropsだけ上書きする」というReactの標準APIです。
const original = <Link to="/profile">ユーザー名</Link>;
const cloned = React.cloneElement(original, { onClick: handleClick });
// <Link to="/profile" onClick={handleClick}>ユーザー名</Link> と同じ
Linkが元々持っていたtoなどのpropsは維持されたまま、onClickだけが追加(すでにあれば上書き)されます。DropdownMenuItemは「どんな要素が渡されるか」を知らなくても、「クリックされたらメニューを閉じる」という振る舞いだけを差し込めるのがポイントです。
クラス名は自分で持っていく必要がある
asChildを使うと、DropdownMenuItemが持っていたdivごと消えるので、そこに付いていた見た目(className="dropdown-menu-item")も一緒に消えます。
// 見た目のクラスはLink側に自分で付ける必要がある
<Link to="/profile" className="dropdown-menu-item">
これを忘れると、機能は直っても見た目が崩れます。「動作を注入してもらう代わりに、外見は自分で持っていく」というのがasChildを使うときの取り決めです。
いつasChildが必要になるか
改めて整理すると、asChildが解決しているのは次のようなケースです。
- ボタンやメニュー項目のクリック範囲を、内側のリンクやボタンの実際の要素いっぱいに広げたい
- アクセシビリティ上、
divではなく本物の<a>や<button>であってほしい(スクリーンリーダーやキーボード操作は、要素の種類で挙動が変わります) <button>の中に<a>を入れるような、HTML的に無効なネストを避けたい
逆に言えば、単に見た目や余白を揃えたいだけなら、divで包んだままCSSでdisplay: blockやflex: 1を指定するほうがシンプルです。asChildは「余計な要素を増やしたくない・要素の種類そのものを外から選ばせたい」ときの選択肢、と捉えておくとよさそうです。
まとめ
divで子要素をラップする実装だと、divに持たせた振る舞い(クリックで閉じる、など)と、子要素そのものが持つ機能(リンクの遷移範囲など)が食い違うことがあるasChildは、ラップ用の要素を作らずに、子要素へpropsだけを注入する設計パターン- 実装は
React.isValidElementで子要素をチェックし、React.cloneElementでpropsを差し込むだけ - 代わりに、見た目のクラス名などは子要素側で持つ必要がある
今回は自分でバグを踏んでからasChildの実装を読みに行きましたが、Radix UIなどのコンポーネントライブラリでこのpropを見かけたときも、同じ理屈で読めるはずです。