Reactに対応していないUIライブラリでもコンポーネントで描画したい


結論

React Portalsを使う。

背景

OpenLayersという地図を描画するライブラリがあります。
canvas上に画像や図形を描画できるのですが、そのcanvasの上からElementを描画するOverlayという機能があります。
しかしOpenLayersはReactに対応していないため、普通に実装すると document.createElement() などを使ってElementを作るしかありません。
そこでReactのPortalsという機能を使ってなんとかコンポーネントで描画できないか考えました。

Portalsとは

ポータル (portal) は、親コンポーネントの DOM 階層外にある DOM ノードに対して子コンポーネントをレンダーするための公式の仕組みを提供します。

要はあるElementに対してReactElementで描画できる手段の一つです。
PortalはElementがどこにあっても描画できるので、ダイアログやモーダルなど最前面に来てほしいElementの描画によく使われています。

使い方

Portalsはこのように使います。

ReactDOM.createPortal(child, container)

childがReactのElement、containerが実際のElementです。

例えば containerというidをもつElementが外部ライブラリによって描画されているとします。
そのElementの子要素としてModalコンポーネントを描画する場合は以下の通りです。

import React, { FC } from "react";
import ReactDOM from "react-dom";
import Modal from "./modal";

const Main: FC = () => {
  const element = document.getElementById("container");
  if (!element) {
    return null;
  }
  return ReactDOM.createPortal(<Modal />, element);
};

const App: FC = () => {
  return <Main />;
};

ReactDOM.render(<App />, document.getElementById("root"));

この場合、 containerが実際どこに描画されていても仮想DOM上は App > Main > Modal の順にツリーができあがります。
(これは React Developer Tools で確認できます)

このようにElementさえ取ってこれればPortalsを通してコンポーネントで描画できるようになります。

ReactDOM.renderとの違い

ReactDOMといえばこのような形でよく見かけると思います。

ReactDOM.render(<App />, document.getElementById("root"))

renderも同じくあるElementに対してReactElementで描画できる手段の一つです。
この2つの違いはReactの仮想DOM上に現れます。

例えば先ほどのcontainer をReactDOM.renderで描画するようにします。

import React, { FC, useEffect } from "react";
import ReactDOM from "react-dom";
import Modal from "./modal";

const Main: FC = () => {
  useEffect(() => {
    const element = document.getElementById("container");
    ReactDOM.render(<Modal />, element)
  }, []);

  return null;
};

const App: FC = () => {
  return <Main />;
};

ReactDOM.render(<App />, document.getElementById("root"));

renderを使う場合とcreatePortalを使う場合の比較は以下の通りです。

どちらを使っても実DOMは変わらないのですが、renderを使うと仮想DOM上はAppとcontainerのツリーは別々になっています。
Portalsを使うとReact上ではあたかもMainの子要素がcontainerであるかのように振る舞います。
そのためAppの中でContextやReduxのProviderを使っている場合、Modalの中でもそれらを使うことができます。
また通常のReactアプリではrenderを1回しか使いません。アンマウント処理も手動でやる必要があるため不便です。

OpenLayersでPortalsを使う

OpenLayersのOverlayをコンポーネントで描画する場合は以下の通りです。
(OpenLayersの仕様に関する説明は省きます)

import "ol/ol.css";
import React, { FC, useEffect } from "react";
import ReactDOM from "react-dom";
import OlMap from "ol/Map";
import Overlay from "ol/Overlay";
import View from "ol/View";
import TileLayer from "ol/layer/Tile";
import OSM from "ol/source/OSM";

const mapId = "map";

const layer = new TileLayer({
  source: new OSM(),
});

const map = new OlMap({
  layers: [layer],
  view: new View({
    center: [0, 0],
    zoom: 2,
  }),
});

const popup = new Overlay({
  element: document.createElement("div"),
});
map.addOverlay(popup);

map.on("click", (evt) => {
  const coordinate = evt.coordinate;
  popup.setPosition(coordinate);
});

const Popup: FC = () => {
  return (
    <div>Popup</div>
  );
};

export const Main: FC = () => {
  const popupElement = popup.getElement();

  useEffect(() => {
    map.setTarget(mapId);
  }, []);

  return (
    <div id={mapId}>
      {popupElement && ReactDOM.createPortal(<Popup />, popupElement)}
    </div>
  );
};

この場合、 popupElement はOpenLayers (つまりReactの外)でレンダリングされます。
ただしReactとOpenLayersではレンダリングシステムが異なるため注意が必要です。
上記の通り、ReactがMapを描画するElementを描画した後に map.setTarget でMapの描画先を指定します。

おわりに

Portalsを使った記事はいくつかありましたが、このようにReactに対応していないUIライブラリと組み合わせた使い方は見なかったため記事にしました。
そもそもReactに対応していないUIライブラリを使うこと自体あまりおすすめはしないのですが、もし使うのであれば素のJSを使ってDOM操作をするよりは遙かにましです。