1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React×OpenLayersでDOMが衝突する原因とcreatePortalでの解決

1
Posted at

はじめに

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 における論理ツリーと物理ツリー

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?