はじめに
React と OpenLayers(以下 OL)を組み合わせた地図アプリで、React と OL がそれぞれ同じ DOM を管理しようとして衝突し、画面がクラッシュする 事象が発生しました。
この記事では、衝突が起きる仕組みと、createPortal を使った解決方法を最小限のサンプルコードで紹介します。
問題
発生した事象
OL の Overlay(地図上の座標に HTML 要素を重ねて表示する OL の機能)に、React で描画した要素を渡すと、その後 React が近くに要素を追加したタイミングで次のエラーが発生し、画面が落ちます。
Uncaught NotFoundError: Failed to execute 'insertBefore' on 'Node':
The node before which the new node is to be inserted is not a child of this node.
再現コード
import { useEffect, useRef, useState } from 'react';
import Overlay from 'ol/Overlay';
import type Map from 'ol/Map';
// 地図上にポップアップを表示するコンポーネント
const Popup = ({ map }: { map: Map }) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
// React が描画した div をそのまま Overlay に渡している
const overlay = new Overlay({ element: ref.current! });
map.addOverlay(overlay);
return () => {
map.removeOverlay(overlay);
};
}, [map]);
return <div ref={ref}>ポップアップ</div>;
};
const App = ({ map }: { map: Map }) => {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(true)}>表示</button>
{show && <p>追加された要素</p>} {/* ← これを表示するとクラッシュ */}
<Popup map={map} />
</>
);
};
原因
insertBefore は「目印」を必要とする
React は要素を追加するとき、「この要素の直前に入れて」という形で DOM を操作します。
親.insertBefore(追加するノード, 目印ノード); // 目印ノードの直前に挿入
このとき、目印ノードが親の子として存在していないとエラー になります。
OL が React 管理の要素を勝手に移動させる
map.addOverlay() は内部で、渡された要素を地図内のコンテナへ appendChild します。appendChild は既存の要素を 移動 させるため、React が描画した div は元の場所から地図の中へ引っ越します。
しかし React はこの移動を知りません。そのため、React の認識と実際の DOM がズレます。
React の認識:
親 ─ [ button ] [ Popup の div ]
実際の DOM(OL が Popup の div を地図内へ移動済み):
親 ─ [ button ]
この状態で <p> を表示しようとすると、React は「Popup の div の直前に入れよう」として次を実行します。
親.insertBefore(p, Popupのdiv); // Popup の div はもう親の子ではない → エラー
OL が要素を移動させるのは仕様通りの動作です。問題は React が管理している要素を、DOM を直接操作するライブラリに渡したこと にあります。
解決方法
React 管理外の「外箱」を OL に渡す
document.createElement で作った素の div(外箱)を OL に渡し、ポップアップの中身は createPortal(React の要素を、別の DOM ノードの中へ描画する React DOM の API)で外箱の中へ描画します。
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import Overlay from 'ol/Overlay';
import type Map from 'ol/Map';
const Popup = ({ map }: { map: Map }) => {
// React が管理しない外箱を 1 度だけ作る
const [container] = useState(() => document.createElement('div'));
useEffect(() => {
// OL に渡すのは外箱
const overlay = new Overlay({ element: container });
map.addOverlay(overlay);
return () => {
map.removeOverlay(overlay);
};
}, [map, container]);
// 中身は外箱の中へ描画する
return createPortal(<div>ポップアップ</div>, container);
};
これで役割がきれいに分かれます。
- OL が動かすのは React 管理外の外箱だけ → React の認識はズレない
- React が管理するのは外箱の中身だけ →
insertBeforeは外箱の内部で完結する -
Popupは元の親の下に DOM を出さない → 壊れた目印ノードが存在しなくなる
論理ツリーと物理ツリーを分けるのが createPortal
createPortal で描画した中身は、論理ツリー(React のコンポーネントの親子関係)では Popup の子のまま です。そのため state・props・context は通常どおり届きます。
一方、物理ツリー(実際の DOM の位置)では、OL が地図内へ運んだ外箱の中 に置かれます。
| 論理ツリー | 物理ツリー | |
|---|---|---|
| 中身の位置 |
App > Popup の子 |
地図 > オーバーレイコンテナ > 外箱の子 |
| 管理者 | React | 外箱は OL、中身は React |
| state / props / context | 通常どおり届く | (影響しない) |
この 2 つを分離できるのが createPortal の強みです。
おわりに
React と、DOM を直接操作するライブラリ(地図・チャート・エディタ系など)を組み合わせるときは、React 管理の要素をライブラリに渡さない のが原則です。ライブラリには素の DOM を渡し、中身は createPortal で描画することで、両者の管理範囲を安全に切り分けられます。
参考
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp

