Menu

Reactのポータル:モーダルやツールチップのためのcreatePortal

createPortalは、Reactのツリーでは同じ場所にとどまったまま、コンポーネントの一部をdocument.bodyのような別のDOMノードに描画します。overflow: hiddenやz-indexの重なり順から抜け出す必要があるモーダル、ツールチップ、メニューに使います。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

ポータルは、Reactのツリーでは同じ場所にとどまったまま、コンポーネントの一部をDOMの別の場所(通常は document.body)に描画します。react-dom の createPortal(children, domNode) で作ります。モーダル、ツールチップ、ドロップダウンメニューは、ポータルによって overflow: hidden や独自の重ね合わせコンテキストを持つ親から抜け出します。

下のボックスは、はみ出たものをすべて切り取ります。両方のヒントを開いてください。

普通のヒントは点線の枠で切れてしまいます。ポータルのヒントは全体が表示されます。そのDOMノードがボックスではなく <body> の子だからです。ボックスから overflow: 'hidden' を削除すると、普通のヒントも切り取られなくなります。

構文

import { createPortal } from 'react-dom';

createPortal(children, domNode, key?)
  • children は任意のJSXです。要素、フラグメント、コンポーネントなどです。
  • domNode は、document.body や document.getElementById('modal-root') のような既存のDOM要素です。ポータルが描画されるときに存在している必要があります。
  • key は省略可能で、ポータルのリストを描画するときに使います。

createPortal は、ほかの要素と同じようにJSXに入れるものを返します。親自身のDOMの位置には何も描画しません。

document.bodyに描画するモーダル

典型的な例はモーダルです。transform を持つカードの中では、position: fixed のオーバーレイはウィンドウではなくそのカードを基準に配置され、さらにカードの overflow: hidden によって切り取られます。document.body に描画すれば、ビューポート全体を覆います。

モーダルを開き、Escapeを押すか暗いオーバーレイをクリックして閉じてください。開くとフォーカスはCloseボタンに移ります。次に、createPortal を使わずにオーバーレイを返してみてください(呼び出しとその document.body 引数を取り除きます)。transform によってカードがfixed要素の包含ブロックになるので、オーバーレイはカードの大きさに縮みます。

イベントはReactのツリーを通って伝わる

ポータルが変えるのはDOMノードの置き場所であって、コンポーネントの位置ではありません。ReactのイベントはReactのツリーを上へ伝わるので、DOMではボタンが <body> の子であっても、ポータルの中のクリックはそれを描画したコンポーネントの onClick に届きます。

ネイティブのリスナーは違います。addEventListener で追加したリスナーはDOMのツリーに従い、そのクリックを受け取ることはありません。

「Inside the wrapper」をクリックすると、Reactのハンドラとネイティブのリスナーの両方がログを出します。「In a portal」をクリックすると、Reactのハンドラだけがログを出します。ポータルのボタンは画面上では点線のラッパーの外にありますが、Reactはそれを子として扱い続けます。

これはたいてい望ましい動きです。メニューの親の onClick はポータルの項目のクリックを受け取り、コンポーネントより上のコンテキストプロバイダーもポータルの中に適用されます。ただし「外側をクリックしたら閉じる」ロジックでは驚くことがあります。メニューを閉じるラッパーの onClick は、メニューのポータルの中のクリックでも発生するので、メニューの中で伝わりを止める(e.stopPropagation()、イベントのページで紹介しています)か、document のネイティブのリスナーを使って、ターゲットがポータルのDOMノードの中にあるかを確認してください。

自分用のコンテナへのポータル

document.body が一番簡単な描画先です。すべてのオーバーレイが1つの場所と1つの重なり順を共有するように、index.html に専用のコンテナを追加するアプリもあります。

<body>
    <div id="root"></div>
    <div id="modal-root"></div>
</body>
createPortal(<Modal />, document.getElementById('modal-root'));

サーバーレンダリング(Next.jsなどのフレームワーク)では、サーバーに document は存在しません。ポータルはコンポーネントがマウントされた後にだけ描画してください。たとえば、エフェクトが true にする mounted stateの後ろに置きます。

ポータルをきっかけとなる要素の隣に配置する必要があるときは、先にその要素を測ります。最初の例ではクリックハンドラの中で測っています。ちらついてはいけないツールチップなら、ブラウザが描画する前に実行されるuseLayoutEffectの中で測ってください。

アクセシブルなモーダル

マークアップを document.body に移しただけでは、キーボードやスクリーンリーダーのユーザーには何の助けにもなりません。モーダルには次のものも必要です。

  • role="dialog" と aria-modal="true"、そしてタイトルを指す aria-labelledby。
  • 開いたときにダイアログの中へフォーカスを移し(例ではCloseにフォーカスしています)、閉じたときにそれを開いたボタンへ戻すこと。
  • Escapeで閉じられること。
  • 開いている間はフォーカスを中にとどめ、Tabで後ろのページへ移動しないようにすること。モーダルが開いている間、アプリのルートに inert 属性を設定すると、そこでのフォーカスとクリックを防げます。

ネイティブの <dialog> 要素は、このほとんどを代わりにやってくれます。dialogRef.current.showModal() で開くと、ブラウザのトップレイヤーで、どのz-indexよりも上に描画され、ページの残りの部分を操作不能にし、Escapeで閉じます。ポータルが不要なので、簡単な確認ダイアログには最適な選択肢です。ポータルは、ツールチップ、メニュー、独自のオーバーレイのための道具であり続けます。

z-indexだけでは足りない理由

大きな z-index でメニューをページのほかの部分より上に出せなかった後に、ポータルに手を伸ばす開発者はよくいます。原因は重ね合わせコンテキストです。position と z-index を持つ要素、1未満の opacity、transform、filter、isolation: isolate を持つ要素は新しい重ね合わせコンテキストを作り、その子の z-index の値は、その中でお互いと競うだけになります。z-index: 1 のカードの中にある z-index: 9999 の子は、z-index: 2 を持つ兄弟のカードより下にあるままです。

ポータルは、要素をそうしたすべての祖先の外へ移します。<body> の子なら、その z-index はページのトップレベルの要素と比べられるので、オーバーレイには 1000 くらいの控えめな値で十分です。

よくある間違い

まだ存在しないノードにポータルを描画する。要素がなければ document.getElementById('modal-root') は null を返し、createPortal は「Target container is not a DOM element」を投げます。HTMLを確認するか、document.body にポータルを描画してください。

レンダリング中に描画先のノードを作る。コンポーネント本体で document.createElement('div') と書くと、レンダリングのたびに新しいノードが作られます。エフェクトで1回だけ作るか、固定のコンテナを使ってください。

きっかけの要素についていかないポップアップ。getBoundingClientRect() から配置したツールチップは、ページがスクロールしたり大きさが変わったりしても、きっかけの要素についていきません。scroll と resize で計算し直す(エフェクトのクリーンアップでそれらのリスナーを削除します)か、ページがスクロールしたらツールチップを閉じてください。

よくある質問

Reactのポータルとは何ですか?

子を、親コンポーネントのDOM要素の外にあるDOMノードに描画する方法です。react-dom の createPortal(children, domNode) で作ります。子はReactのツリーでの位置を保つので、props、state、コンテキストはいつもどおりに動きます。

ポータルはいつ使うべきですか?

何かをコンテナの上や外に表示する必要があるときです。モーダル、ツールチップ、ドロップダウンメニュー、トーストなどです。そうしないと、overflow: hidden、transform、独自の重ね合わせコンテキストを持つ親が、それを切り取ったり隠したりしてしまいます。

イベントはポータルの外へ伝わりますか?

はい、Reactのツリーを通って伝わります。ポータルの中のクリックは、DOMノードが document.body にあっても、Reactの親の onClick ハンドラに届きます。addEventListener で追加したネイティブのリスナーは、代わりにDOMのツリーに従います。

ポータルの中でもコンテキストは使えますか?

はい。コンテキストもイベントと同じように、Reactのツリーに従います。document.body に描画したモーダルも、それを作ったコンポーネントより上にあるプロバイダーから、テーマやユーザーを読めます。

モーダルにはポータルが必要ですか?

必ずしも必要ではありません。showModal() で開いたネイティブの <dialog> 要素は、ブラウザのトップレイヤーで、どのz-indexよりも上に描画され、フォーカスの扱いも組み込まれています。ポータルは、独自のモーダルや、ツールチップ、メニューのための一般的な選択肢です。

Coddyのプログラミング言語のイラスト

Coddyでコードを学ぼう

始める