· yuta

Reactの asChild パターンをドロップダウンメニューの実装から理解する

#react#typescript#frontend

個人開発中のアプリで、自作のドロップダウンメニューにリンクを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で包んだままCSSでdisplay: blockやflex: 1を指定するほうがシンプルです。asChildは「余計な要素を増やしたくない・要素の種類そのものを外から選ばせたい」ときの選択肢、と捉えておくとよさそうです。

まとめ

今回は自分でバグを踏んでからasChildの実装を読みに行きましたが、Radix UIなどのコンポーネントライブラリでこのpropを見かけたときも、同じ理屈で読めるはずです。