The Leaflet map that survived a theme switch | WaretaGarasuSkip to main content
WaretaGarasu
Language
Theme
Glass
All writingNote · Leaflet

The Leaflet map that survived a theme switch

A React wrapper around an imperative map library hides a sharp edge: React re-renders, but Leaflet painted once. Three small fixes keep the stats map honest.

LeafletReactThemingDebugging

The setup

The stats page has a small world map: visitor locations drawn as green circles over grey landmasses, built with react-leaflet. It has two jobs that sound trivial and are not. It has to recolor when the site theme flips between dark and light, and the green markers have to stay on top of the land, always.

Both broke, in different ways, for the same underlying reason: a declarative React wrapper sitting on top of a library that draws imperatively.

Leaflet paints once

Leaflet builds the map when the container mounts and then mutates it through its own API. The land fill and the ocean background are decided at construction. Passing new colors down as React props after that does nothing, because Leaflet is not re-reading those props on every render. Toggle the theme and the map stays in the old palette while everything around it changes.

// Leaflet paints the map imperatively, once. New color props on later renders
// don't repaint it, so a theme toggle left the map in the old palette.
function useMapTheme(): Theme {
  const [theme, setTheme] = useState<Theme>(readTheme)
  useEffect(() => {
    const obs = new MutationObserver(() => setTheme(readTheme()))
    obs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['data-theme'],
    })
    return () => obs.disconnect()
  }, [])
  return theme
}

// key={theme} discards the old map and builds a fresh one in the new palette.
<MapContainer key={theme} style={{ background: oceanBg }} /* ... */ />

A MutationObserver watches the data-theme attribute on the html element and turns it into React state. That state becomes the key on MapContainer, so a theme change throws the old map away and constructs a fresh one in the new palette. Crude, but it is the honest way to force an imperative widget to repaint.

Keep the remount cheap

Remounting has a cost: the world borders are a GeoJSON file fetched from /world.geo.json, and a naive remount re-downloads and re-parses it on every toggle. That is a visible flash and wasted bytes for a purely cosmetic change.

// A remount on every toggle would re-fetch and re-parse the world borders.
// Hoisting the parsed GeoJSON to module scope outlives remounts (and React
// StrictMode's dev double-invoke), so the toggle stays instant after first paint.
let _worldCache: object | null = null

function StatsMap({ locations }: StatsMapProps) {
  const [worldData, setWorldData] = useState<object | null>(_worldCache)

  useEffect(() => {
    if (_worldCache !== null) {
      if (!worldData) setWorldData(_worldCache)
      return
    }
    let alive = true
    fetch('/world.geo.json')
      .then((r) => r.json())
      .then((data) => {
        _worldCache = data
        if (alive) setWorldData(data)
      })
      .catch(() => {})
    return () => { alive = false }
  }, [])
  // ...
}

The fix is to hoist the parsed GeoJSON to a module-level variable, outside the component. It survives remounts, and it survives React StrictMode's deliberate double-invoke in development. The effect reads from the cache instantly when it is warm and only fetches on a genuine cold load, so the theme toggle stays instant after the first paint.

Markers under the land

The second bug: on a cold load the green markers sometimes vanished under the landmasses. Leaflet renders the GeoJSON land and the circle markers into the same overlay-pane SVG, and in SVG paint order follows DOM order. Whatever comes last is drawn on top.

// Land and markers share Leaflet's overlay-pane SVG, where paint order is DOM
// order. On a cold load the markers could mount before the async GeoJSON
// resolved, so the land painted last and hid them. Gate BOTH on worldData so
// they always mount together: land first, markers above.
{worldData && (
  <>
    <GeoJSON data={worldData} style={{ fillColor: landFill, color: landStroke }} />
    {valid.map((loc, i) => (
      <CircleMarker key={i} center={[loc.lat, loc.lon]} pathOptions={{ fillColor: '#22c55e' }} />
    ))}
  </>
)}

On a warm load the order happened to be right. On a cold load the markers could mount before the async GeoJSON resolved, so the land painted last and covered them. The fix is to gate both the land and the markers behind the same worldData check, so they only ever mount together, land first and markers second. Order guaranteed, not coincidental.

The takeaway

When a declarative wrapper sits on top of an imperative drawing library, stop expecting props to behave like state. Remount to force a redraw, cache the expensive input at module scope so the remount stays cheap, and respect the library's paint model instead of fighting it. In a shared SVG pane, DOM order is z-order, so control the order and the layering takes care of itself.