← Back to Blog

Render an Esri popup in a React panel without it vanishing

Render an Esri popup in a React panel without it vanishing

You want an authored ArcGIS popup — title, media, Arcade expressions and all — rendered inline in a docked React panel instead of floating over the map. The Maps SDK ships exactly the tool for it: the Feature widget. You mount it on a React ref’d <div>, it reports success… and the panel sits stubbornly blank.

This one cost us eleven fix rounds across three days before we saw what was actually happening. The widget wasn’t failing — it was rendering into a node React had quietly thrown out of the document. Here is the exact failure, why it happens, and the one-line-of-intent fix we now use in every ATS Esri-on-React app.

The symptom that lies to you

Everything says it worked. The widget constructs without error. widget.destroyed is false. The container has ~4 KB of innerHTML, and its first child is esri-feature__size-container — proof Esri rendered real content. But the panel is empty. One probe ends the mystery:

// WRONG: hand Esri your React-owned ref div
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  new Feature({ container: containerRef.current!, graphic });
}, [graphic]);

return <div ref={containerRef} />;

// containerRef.current.parentElement === null
// The widget rendered ~4KB of HTML into a node that is no longer in the document.

The widget’s container has no parent. Esri detached your React-owned <div> from the DOM tree and kept happily rendering into the orphan.

Why it happens

Two libraries are fighting over one DOM node. React believes it owns every element it created via JSX and reserves the right to move, replace, or remove it on any re-render. The Esri widget, handed that same node, mutates and re-parents it as part of its own layout work. The moment React reconciles and the widget has restructured “its” element, the node you can see in the panel and the node the widget is drawing into are no longer the same connected element. The content is real; it’s just not on screen.

CSS can’t save you here — we tried. Min-height, flex chains, .esri-widget overrides: none of it computes on an element that isn’t in the document. The fix has to be structural.

The fix: give Esri a div React never tracks

Keep your React ref’d container as a stable host — but never hand it to Esri. Instead, create a fresh inner <div> with document.createElement, append it to the host, and give that throwaway node to the widget. React has no knowledge of it, so React never reconciles, moves, or detaches it:

import Feature from "@arcgis/core/widgets/Feature.js";

const hostRef = useRef<HTMLDivElement>(null);      // stable, React-owned
const widgetRef = useRef<Feature | null>(null);

useEffect(() => {
  const host = hostRef.current;
  if (!host) return;
  let cancelled = false;

  // tear the previous widget down BEFORE the async hydrate, so rapid
  // clicks never stack two widgets in the same panel
  widgetRef.current?.destroy();
  widgetRef.current = null;
  host.innerHTML = "";

  (async () => {
    const full = await hydrateGraphic(graphic);
    if (cancelled) return;
    const mount = document.createElement("div");   // the fix: a node React never tracks
    host.appendChild(mount);
    widgetRef.current = new Feature({
      container: mount,
      graphic: full,                                // full.layer carries the popupTemplate + Arcade
      // map, spatialReference,                       // add ONLY if your Arcade references $map
    });
  })();

  return () => { cancelled = true; };
}, [graphic]);

// destroy on unmount so the widget never outlives the panel
useEffect(() => () => { widgetRef.current?.destroy(); }, []);

return <div ref={hostRef} className="ats-feature-host" />;

That’s the whole trick. The ref’d host is React’s; the mount div is Esri’s; neither library touches the other’s node.

Feed it a full graphic, or Arcade renders blank

A second, quieter failure waits behind the first. A hitTest or identify result usually carries only the OBJECTID plus a few display fields — not enough for the authored popup’s Arcade expressions to evaluate, so sections render empty. Re-query the source layer with outFields: ["*"] and re-stamp the layer so the widget can find the template:

async function hydrateGraphic(graphic: Graphic): Promise<Graphic> {
  const layer = graphic.layer as FeatureLayer | undefined;
  if (!layer?.queryFeatures) return graphic;

  const oidField = layer.objectIdField ?? "OBJECTID";
  const oid = graphic.attributes?.[oidField] ?? graphic.getObjectId?.();
  if (oid == null) return graphic;

  const { features } = await layer.queryFeatures({
    objectIds: [Number(oid)], outFields: ["*"], returnGeometry: true,
  });
  const full = features[0] ?? graphic;
  if (!full.layer) full.layer = layer;   // re-stamp so the widget finds the popupTemplate
  return full;
}

Lifecycle: destroy before you rebuild

Three rules keep the panel honest under real use. Destroy the previous widget synchronously, before the async hydrate — otherwise a fast click-through stacks half-built widgets in one panel. Guard the async path with a cancelled flag so a slow query that resolves after the user has already navigated away doesn’t mount a stale widget. And destroy on unmount from a second effect with an empty dependency array, so the widget never outlives its panel. The v5 Feature widget does not reliably swap a graphic in place — rebuild it from scratch on every selection. It’s cheap, and it’s correct.

The constructor signature

Minimal and deliberate: { container, graphic }. The widget reads the authored popupTemplate — Arcade, media, conditional text — from graphic.layer, which is why hydration re-stamps .layer. Add map and spatialReference only if your Arcade expressions reference $map; for most popups you don’t need them, and passing a view here is the wrong object.

A note on v5 and the road ahead

The classic Feature widget is deprecated in ArcGIS Maps SDK for JavaScript 5.x — the deprecation notice points to the <arcgis-feature> map component as its replacement — but the widget still ships and works in 5.x (Feature widget — API reference). We deliberately keep the class rather than adopt the web component, because our brand apps own their visual system end to end and don’t pull in the Map Components / Calcite layer. If you’re on that same footing, the pattern above is the durable one until Esri removes the widget outright (watch for it around @arcgis/core v6–v7); the fallback then is PopupTemplate.fetchContent() plus an Arcade executor, or accepting the web component.

Scoping the widget’s styles into your panel

The widget brings its own chrome. Scope your brand overrides to the host so it lines up with the surrounding card instead of bleeding the SDK’s default popup styling across your app:

/* brand the Feature widget inside your React panel */
.ats-feature-host .esri-widget,
.ats-feature-host .esri-feature { background: #fff; color: var(--color-ats-navy); padding: 0; }
.ats-feature-host .esri-feature-media img { max-width: 100%; height: auto; }
.ats-feature-host .esri-feature-media__chart canvas { min-height: 120px !important; }

References

Building Esri maps in React or another modern framework?

We build premium ArcGIS apps on React, Vite and TypeScript — SDK widgets wired into a framework cleanly, authored popups rendered where you want them, no fighting the DOM. If your team is stuck between Esri and React, let’s talk.

Book a free intro call