全面ReactなSPAではなく、部分的にReactを導入しているようなサービスにモーダルとそれを表示させるボタンをReact Component で作ろうとすると次のような構成になるかと思います。
function ShowDetailByModal() { return ( <> <button onClick={showModal}> SHOW </button> <Modal /> </> ); }
しかし、このような構成の場合コンポーネント内にモーダルのDOMが出力されるので、このコンポーネントが置かれる親要素に position
や z-index
、overflow: hidden
といったCSSを持っているものがあると、意図したとおりにモーダルが表示されなくなってしまいます。(CSSの影響大きいよね…)
モーダルのDOMだけ<body>
直下に置きたい。
つまり、特定の子コンポーネントのDOMをコンポーネント外のDOMに追加したいようなケースのメモ。
Portal を使う
ポータル (portal) は、親コンポーネントの DOM 階層外にある DOM ノードに対して子コンポーネントをレンダーするための公式の仕組みを提供します。
ReactDOM.createPortal(child, container)第 1 引数 (child
) は React の子要素としてレンダー可能なもの、例えば、要素、文字列、フラグメントなどです。第 2 引数 (container
) は DOM 要素を指定します。
cf. https://ja.reactjs.org/docs/portals.html
モーダルを追加するDOMを前もって用意する方法
HTMLに、別途追加したいDOMを入れる要素を追加します。
今回はボタンと別にモーダルだけ body
直下に追加したいので、<div id="modal"></div>
を追加しました。
<html> <body> <div id="app"> <nav id="nav"> <!-- ここにモーダルを表示するボタンが入る --> </nav> </div> <div id="modal"><!-- ここにModalが入る --></div> </body> </html>
Portal Component を作成
// Portal.js import ReactDOM from 'react-dom'; export default function Portal({children, targetID}) { return ReactDOM.createPortal( children, document.getElementById(targetID) ); }
モーダルを表示するコンポーネントを作成
// ShowDetailByModal.js import React, { useState } from 'react'; import Portal from './Portal'; function ShowDetailByModal() { const [isOpen, setIsOpen] = useState(false); const showModal = () => setIsOpen(true); const closeModal = () => setIsOpen(false); return ( <> <button onClick={showModal}> SHOW </button> <Portal targetID="modal"> <Modal isShow={isOpen} onClose={closeModal} /> </Portal> </> ); } ReactDOM.render( <ShowDetailByModal />, document.getElementById('nav') );
👇 Render
<div id="app"> <nav id="nav"> <button>button</button> </nav> </div> <div id="modal"> <div class="modal-component">…</div> </div>
Portalを使えば、ReactのDevツールで見た感じだと VDOM上 <Modal>
Component はボタンのある <ShowDetailByModal>
Component の中にあり通常の親-子コンポーネントと同じように props を渡したりできるが、実DOMでは子コンポーネントが親コンポーネント外に自由な場所に置くことができました!
Portal で出力する DOM をコンポーネント内で作成する方法
HTMLで別途追加するコンポーネントを入れるDOMを用意するのではなく、React内で追加するDOMも作成してしまう方法
HTML
<html> <body> <div id="app"> <nav id="nav"> <!-- ここにモーダルを表示するボタンが入る --> </nav> </div> <!-- ここに Portal を出力したい --> </body> </html>
Portal Component
// Portal.js import { useState, useEffect } from 'react'; import ReactDOM from 'react-dom'; export default function Portal({children}) { const el = document.createElement('div'); const [portal, setPortal] = useState(el); // portal を実DOMに追加 useEffect(() => { const body = document.querySelector('body'); body.appendChild(el); setPortal(el); }, []); return ReactDOM.createPortal( children, portal ); }
子要素を追加する DOM を React の Functional Component で動的に作成して追加する際のポイント
- 子要素を追加する DOM エレメントを
useState
で保持していること - 実DOMに追加する
useEffect
の第二引数に[]
を渡して初回のみ実DOMに追加するようにしていること
🙆♀️ (追記) 追加する親要素を Functional Component 外で作成する方法
React の Functional Component 関数外であれば再描画の際に再実行されることがないので、動的に追加する親要素を React の関数外で作成してしまえば useState
で親要素の el
を保持しなくても大丈夫でした!
import { useEffect } from 'react'; import ReactDOM from 'react-dom'; // 親要素を React の Component 関数 外で定義 const el = document.createElement('div'); export default function Portal({children}) { // portal を実DOMに追加 useEffect(() => { const body = document.querySelector('body'); body.appendChild(el); }, []); return ReactDOM.createPortal( children, el ); }
再描画の際に Portal
関数は再実行されるけど、ReactDOM.createPortal(children, el)
で指定している親要素 el
は関数外で作成したものを参照しているので、問題なく再描画されるようです。
Portal コンポーネントに prop で親要素 el
のクラス名を与えたい場合などは useEffect
の中で変更することができます。el
を let
で定義してしまえば useEffect
内で div
以外に変更してしまうことも可能でした。
import { useEffect } from 'react'; import ReactDOM from 'react-dom'; // 親要素を React の Component 関数 外で定義 let el = document.createElement('div'); export default function Portal({children, classNames}) { // portal を実DOMに追加 useEffect(() => { // ▼ この変更は再描画の際も保持されている ▼ // 親 DOM を変更してしまう el = document.createElement('span'); // props 経由での加工もOK el.className = classNames.join(' '); // ▲ この変更は再描画の際も保持されている ▲ const body = document.querySelector('body'); body.appendChild(el); }, []); return ReactDOM.createPortal( children, el ); }
state 管理するまでも無いものなので、コンポーネント関数外で親要素を定義してしまうほうが楽かもしれません。(関数的にはあまり美しくはないかもだけど)
上手く動作しないパターン
🙅♀️ 1. 子要素を追加する親DOMが保持されず、再描画の際に Portal 内が描画されなくなるパターン
Functional Component では再描画の度に関数が実行されるので、子要素を追加するDOMが保持されてないと、再描画の際に ReactDOM.createPortal()
で追加する親要素が不明になってしまって描画されなくなります。
import { useEffect } from 'react'; import ReactDOM from 'react-dom'; export default function Portal({children}) { const el = document.createElement('div'); useEffect(() => { const body = document.querySelector('body'); body.appendChild(el); }, []); return ReactDOM.createPortal( children, el ); }
再描画の際に ReactDOM.createPortal(children, el)
で children
を追加する el
は初回描画時の el
とは別物で実DOMに追加もされてないので、children
は描画されなくなる
🙅♀️ 2. 再描画の度に子要素を追加する親要素のDOMが追加されてしまう
useEffect
の第二引数に []
を渡していない場合、再描画の度に新しい <div>
が実DOMに追加されてしまいます。
import { useEffect } from 'react'; import ReactDOM from 'react-dom'; export default function Portal({children}) { const el = document.createElement('div'); // 再描画の度に useEffect が実行されてしまう useEffect(() => { const body = document.querySelector('body'); body.appendChild(el); }); return ReactDOM.createPortal( children, el ); }
再描画の度に新しい el
が実DOMに追加され、その新しく追加された el
(<div>
) 内に children
が出力されてしまいます。
🙅♀️ 3. useEffect の中で ReactDOM.createPortal
することは出来ない
初回だけ実行される useEffect
内で Portal を出力すれば良いのでは?と思ったのですが、エラーになります。
import { useEffect } from 'react'; import ReactDOM from 'react-dom'; export default function Portal({children}) { useEffect(() => { const el = document.createElement('div'); const body = document.querySelector('body'); body.appendChild(el); ReactDOM.createPortal( children, el ); }, []); return; }
そもそもこのコンポーネントが何も返さなくなり、VDOMの方が成立しなくなる為ではないかと推測しています。
return (children)
の様な感じで何か返すようにするとエラーにはなりませんが、ReactDOM.createPortal
での追加は無効化されてしまうのでダメなようです。
所感
VDOMツリーにある子要素を、実DOMでは別の箇所に追加できる方法が用意されていたのは今回調べて初めて知りました。
めっちょべんりー!! すごーい!!!!
ただ、今回のような方法はモーダルを追加するボタンごとにモーダルが出力されるので、モーダルを使うような場合はSPAのように全体をReact Component化してモーダルコンポーネントは1つだけ用意して Redux や useContext
などを通じてアクセスできるようにしておき、各ボタンのアクションがあれば、唯一のモーダルの中身を変えて表示するような設計がキレイそうだな〜とか考えてました。
公式ドキュメントが Class Component で書かれていたので、これが本当に正しいのかな?って部分は気になる
[参考]
- アーティスト:さよならポニーテール
- 発売日: 2019/11/13
- メディア: CD