← All posts

Wrapping Leaflet in a Stimulus Controller

March 20, 2024 rails stimulus

Leaflet is a lightweight mapping library that does one thing well — interactive maps without the complexity (or cost) of Google Maps. This post wraps Leaflet in a Stimulus controller so you can add maps to any Rails view with a partial and a few data attributes.

This follows the same partial + controller pattern from Rails Partials + Stimulus as Reusable Components.

Install Leaflet

yarn add leaflet

Add the CSS to your layout head:

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">

Or import it from the package if your bundler handles CSS:

@import "leaflet/dist/leaflet.css";

The Controller

app/javascript/controllers/map_controller.js:

import { Controller } from "@hotwired/stimulus"
import L from "leaflet"

export default class extends Controller {
  static targets = ["container"]
  static values = {
    center: { type: Array, default: [44.9778, -93.2650] },
    zoom: { type: Number, default: 13 },
    markers: { type: Array, default: [] },
    tileUrl: { type: String, default: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" },
    attribution: { type: String, default: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' }
  }

  connect() {
    this.map = L.map(this.containerTarget).setView(this.centerValue, this.zoomValue)

    L.tileLayer(this.tileUrlValue, {
      attribution: this.attributionValue,
      maxZoom: 19
    }).addTo(this.map)

    this.markerLayer = L.layerGroup().addTo(this.map)
    this.addMarkers()
  }

  disconnect() {
    this.map.remove()
    this.map = null
  }

  addMarkers() {
    this.markersValue.forEach(marker => {
      const m = L.marker([marker.lat, marker.lng]).addTo(this.markerLayer)
      if (marker.popup) {
        m.bindPopup(marker.popup)
      }
    })

    if (this.markersValue.length > 1) {
      const group = L.featureGroup(this.markerLayer.getLayers())
      this.map.fitBounds(group.getBounds().pad(0.1))
    }
  }

  markersValueChanged() {
    if (!this.map) return
    this.markerLayer.clearLayers()
    this.addMarkers()
  }
}

The controller creates a map on connect and tears it down on disconnect — important for Turbo compatibility. Markers are passed as a JSON array through a Stimulus value. When the markers value changes (useful with Turbo Streams), the map updates automatically via markersValueChanged().

If there are multiple markers, fitBounds adjusts the viewport to show all of them. The pad(0.1) adds a 10% buffer so markers aren’t right at the edge.

Fix the Default Marker Icon

Leaflet’s default marker icon breaks with most bundlers because the image paths get mangled. Fix it once in the controller’s connect():

connect() {
  // Fix default icon paths
  delete L.Icon.Default.prototype._getIconUrl
  L.Icon.Default.mergeOptions({
    iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
    iconUrl: require("leaflet/dist/images/marker-icon.png"),
    shadowUrl: require("leaflet/dist/images/marker-shadow.png"),
  })

  this.map = L.map(this.containerTarget).setView(this.centerValue, this.zoomValue)
  // ...
}

If you’re using esbuild with the asset pipeline, you may need to copy the images to your assets directory instead. The specifics depend on your build setup.

The Partial

app/views/components/_map.html.erb:

<div data-controller="map"
     data-map-center-value="<%= center.to_json %>"
     data-map-zoom-value="<%= zoom || 13 %>"
     data-map-markers-value="<%= markers.to_json %>"
     style="height: <%= height || '400px' %>;">
  <div data-map-target="container" style="height: 100%;"></div>
</div>

Usage

Show a single location:

<%= render partial: "components/map", locals: {
  center: [44.9778, -93.2650],
  zoom: 15,
  height: "300px",
  markers: [
    { lat: 44.9778, lng: -93.2650, popup: "<strong>Minneapolis</strong>" }
  ]
} %>

Show multiple locations from a collection:

<%= render partial: "components/map", locals: {
  center: [44.9778, -93.2650],
  markers: @locations.map { |loc|
    { lat: loc.latitude, lng: loc.longitude, popup: loc.name }
  }
} %>

The map auto-zooms to fit all markers when there’s more than one.

Custom Marker Icons

For different marker types (e.g., color-coding by category), extend the controller to support icon options:

addMarkers() {
  this.markersValue.forEach(marker => {
    const options = {}

    if (marker.icon) {
      options.icon = L.icon({
        iconUrl: marker.icon,
        iconSize: [25, 41],
        iconAnchor: [12, 41],
        popupAnchor: [1, -34]
      })
    }

    const m = L.marker([marker.lat, marker.lng], options).addTo(this.markerLayer)
    if (marker.popup) {
      m.bindPopup(marker.popup)
    }
  })
}

Pass the icon URL in the marker data:

markers: @stores.map { |s|
  { lat: s.lat, lng: s.lng, popup: s.name, icon: asset_path("markers/#{s.category}.png") }
}

Adding Drawing or Shape Layers

For boundaries, delivery zones, or service areas, add GeoJSON support:

static values = {
  // ...existing values
  geojson: { type: Object, default: {} }
}

connect() {
  // ...existing setup
  if (Object.keys(this.geojsonValue).length > 0) {
    this.geojsonLayer = L.geoJSON(this.geojsonValue, {
      style: { color: "#0d6efd", weight: 2, fillOpacity: 0.1 }
    }).addTo(this.map)
  }
}

Tips

  • Always call map.remove() in disconnect(). Leaflet attaches event listeners to the window. Without cleanup, Turbo navigation will leak memory and cause errors.
  • Set an explicit height. Leaflet requires a height on the container element. Without it, the map renders with zero height and nothing is visible.
  • Tile providers. OpenStreetMap tiles are free but have a usage policy. For production apps with significant traffic, use a commercial provider like Mapbox, Stadia Maps, or Thunderforest.
  • fitBounds vs. setView. Use setView when you know the exact center and zoom. Use fitBounds when you want the map to frame a set of points automatically. Don’t use both — fitBounds overrides the initial view.
  • Invalidate size after show. If the map is inside a tab or accordion that starts hidden, call this.map.invalidateSize() when the container becomes visible. Leaflet can’t calculate dimensions for hidden elements.
Let’s work together! Tell Me About Your Project