skip to content

In development, a React component that creates a map with `L.map(node)` inside an effect ends up with two maps stacked in its container, or the library throws that the container is already initialized. What is React doing, and what is the correct fix?

level: seniorimportance: should knowfreq 50%

answer

  1. development remount is a probe
  2. setup, cleanup, setup again
  3. the cleanup is what failed
  4. a sentinel guard leaves you with nothing
  5. give each setup a fresh node

basics

~20 s

React StrictMode mounts the component, runs the effect's setup, immediately runs its cleanup, then sets up again, to expose incomplete teardown. Two maps means the cleanup never destroyed the first one, so fix the cleanup rather than the double mount.

solid answer

~50 s

In development, `<StrictMode>` remounts every component once: setup, cleanup, setup again. That is a probe, not a bug — it asserts that your effect can be torn down and re-established. If two maps appear, the cleanup did not fully undo the setup; if the library complains the container is already initialized, the first instance is still holding that node. The fix is in the cleanup: call the library's destructor, `map.remove()` here, and null the instance ref, so the second setup starts from a clean container. If the library leaves markup behind or refuses to reinitialize the same element, have the effect create its own child node, hand that to the library, and remove the node in cleanup — every setup then gets a fresh element. What I would not do is add an `if (initialized) return` guard, delay with a timer, or drop StrictMode: the same setup-cleanup-setup sequence happens in production on any real remount, so a guard leaves you with an empty container instead of two maps.

code

javascript · 26 lines
javascript
import { useEffect, useRef } from 'react';
import L from 'leaflet';

export function MapView({ center, zoom }) {
  const containerRef = useRef(null);
  const mapRef = useRef(null);

  useEffect(() => {
    // a fresh host node per setup: the library never sees a
    // container it has already initialized
    const host = document.createElement('div');
    host.style.height = '100%';
    containerRef.current.appendChild(host);

    const map = L.map(host).setView(center, zoom);
    mapRef.current = map;

    return () => {
      map.remove();
      host.remove();
      mapRef.current = null;
    };
  }, []);

  return <div ref={containerRef} style={{ height: 400 }} />;
}

go deeper

for a junior

Know that in development React deliberately mounts, cleans up and mounts again, and that a duplicated widget means your effect's cleanup is not destroying what the setup created.

for a middle

Explain the probe and the mirror: the cleanup must undo every part of setup — destructor, instance reference, appended nodes, listeners — and say why the second setup must find the container as clean as the first did.

for a senior

Argue why the sentinel guard is a regression: cleanup still runs, so you trade duplicates for a dead component after any real remount, and demonstrate the fresh-host-node technique for libraries that cannot reinitialize on the same element.

for a principal

Treat StrictMode as a cheap invariant checker the team should keep on everywhere, and make effect idempotence a review standard so integration bugs surface in seconds locally rather than as slow leaks in production.

## What React is doing When the app is wrapped in `<StrictMode>`, React in development mounts a component, runs its effects, immediately runs all of their cleanups, and then runs the effects again. Nothing about production changes; this is a deliberate probe of one property: **can this effect be torn down and set up again without leaving damage behind?** React needs that property to be true because real applications tear effects down and set them up again all the time. So the duplicate map is not a StrictMode bug that got past you. It is StrictMode reporting a bug you already had. ## Why the symptom looks like a framework problem The report is always some version of "it works without StrictMode". That is true and irrelevant. The identical sequence happens in production whenever a component genuinely remounts: the user navigates away and back, a `key` on the wrapper changes, a conditional branch flips, a parent re-creates the subtree. In all of those cases setup runs again after cleanup — and if cleanup was incomplete, production accumulates orphaned instances instead of showing you one obvious duplicate in development. ## Diagnosing which half is wrong Ask what the setup created and check the cleanup line by line against it. Typical omissions: - the destructor is never called at all, because the developer assumed removing the container div was enough; - the destructor is called but the instance ref is left pointing at a dead object, so later effects operate on a corpse; - the library appended something outside the container — a tooltip layer, an overlay, a portal root on `document.body` — that the destructor does not remove; - setup also registered a window listener, `ResizeObserver`, or `requestAnimationFrame` loop that cleanup forgets; - the library mutated the container and its destructor leaves that markup in place, so the second `L.map(node)` sees an element it already owns. ## The fix Make cleanup an exact mirror of setup: ```js useEffect(() => { const map = L.map(containerRef.current).setView(center, zoom); mapRef.current = map; return () => { map.remove(); mapRef.current = null; }; }, []); ``` Note that the cleanup closes over the local `map`, not over `mapRef.current`. That matters: it tears down the instance *this* setup created, even if something has already replaced the ref's contents. When the library's destructor is not trustworthy, or when it refuses to initialize twice on the same element, stop reusing the element. Let the effect create its own host node inside the React-owned container and remove it in cleanup. Every setup then receives a brand-new, pristine element, and the container is guaranteed empty afterwards regardless of what the library left behind. ## Why the guard is the wrong fix The reflex fix is a sentinel: ```js if (initializedRef.current) return; // don't do this initializedRef.current = true; ``` This suppresses the second *setup* while leaving the *cleanup* in place. In the StrictMode sequence the cleanup still destroys the first instance, and the guard then blocks the replacement — so you trade two maps for none, usually noticed later as a blank panel. If you also reset the flag in cleanup, the guard does nothing at all, which at least tells you it was never the fix. And any variant that keeps the flag alive across a remount (a module-level flag, a flag on a ref preserved by state-reuse features) breaks the component permanently after its first teardown. Deleting `<StrictMode>` is the same mistake with a wider blast radius: it silences this detector and every other one, for the whole app. ## Idempotence is the real requirement The property to aim for is that setup followed by cleanup returns the world to where it started, and that running the pair repeatedly is indistinguishable from running it once. Write cleanup at the same time as setup, in the same effect, and read them together as a pair. Applied consistently, StrictMode stops producing surprises: you get the double mount, nothing visibly changes, and that silence is the signal that the wrapper will survive a real remount too. ## A quick checklist Destructor called; instance ref nulled; nodes appended outside the container removed; listeners, observers and animation frames unregistered; the second setup receives a container in the same state as the first; and the cleanup references the instance from its own closure rather than whatever the ref currently holds.

  • A colleague says this is a development-only artifact and ships the guard anyway. What is your argument?
    The same sequence happens in production on any genuine remount: back-navigation, a changed key, a conditional branch. With the guard, cleanup still destroys the instance while the second setup is skipped, so the user gets an empty container. Without the guard and with proper teardown, both cases work. StrictMode only made the defect visible in seconds instead of in a bug report.
  • The library's destructor leaves its markup inside the container, so the second initialization throws. How do you handle it?
    Stop handing the library the React-owned element. In the effect, create a child node, append it to the container, initialize the library against that node, and remove the node in cleanup. Every setup then gets a fresh element the library has never seen, and the container is verifiably empty after teardown whatever the destructor forgot.
  • Why should the cleanup close over the local instance instead of reading the ref?
    Because the ref may already point at a newer instance by the time cleanup runs. Closing over the local variable guarantees you destroy exactly what this setup created, which is the definition of a mirrored teardown. Reading the ref risks destroying the replacement, or destroying nothing if the ref was nulled elsewhere.

saying these in an interview costs you the question

  • Calls it a StrictMode bug rather than a cleanup bug
  • Adds an initialized ref guard to skip the second setup
  • Removes StrictMode from the app to make it stop
  • Wraps initialization in a setTimeout to dodge the remount
  • Assumes removing the container div disposes the instance

context