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
- Feature widget — ArcGIS Maps SDK for JavaScript API reference
- PopupTemplate — ArcGIS Maps SDK for JavaScript API reference
- Programming patterns — ArcGIS Maps SDK for JavaScript
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