かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React Hooks コンポーネント外のDOMに子コンポーネントを追加したい。

全面ReactなSPAではなく、部分的にReactを導入しているようなサービスにモーダルとそれを表示させるボタンをReact Component で作ろうとすると次のような構成になるかと思います。

function ShowDetailByModal() {
  return (
    <>
      <button onClick={showModal}>
        SHOW
      </button>
      <Modal />
    </>
  );
}

しかし、このような構成の場合コンポーネント内にモーダルのDOMが出力されるので、このコンポーネントが置かれる親要素に positionz-indexoverflow: 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 で動的に作成して追加する際のポイント

  1. 子要素を追加する DOM エレメントを useState で保持していること
  2. 実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 の中で変更することができます。ellet で定義してしまえば 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 で書かれていたので、これが本当に正しいのかな?って部分は気になる


[参考]

ROM

ROM