React Map GL

Complete guide to using React Map GL for building interactive maps in Next.js applications with Mapbox GL JS and MapLibre GL JS.

Introduction to React Map GL

React Map GL is a suite of React components that provide a fully reactive wrapper for Mapbox GL JS and MapLibre GL JS. Created by Uber's Visualization team (part of vis.gl), it enables developers to build powerful, performant mapping applications using familiar React patterns and reactive programming principles.

React Map GL

Key Features

Fully Reactive Architecture

  • Controlled components with React state management
  • Declarative API for map configuration
  • Seamless integration with React ecosystem
  • Predictable state synchronization

Dual Library Support

  • Works with both Mapbox GL JS and MapLibre GL JS
  • Easy switching between libraries
  • Consistent API across both implementations
  • Future-proof architecture

High Performance

  • WebGL-based rendering
  • Efficient handling of large datasets
  • Optimized for mobile devices
  • Smooth animations and transitions

Rich Component Library

  • Map container with full camera control
  • Markers, popups, and navigation controls
  • Source and layer management
  • GeolocateControl and ScaleControl

Developer Experience

  • TypeScript definitions included
  • Comprehensive documentation
  • Extensive example collection
  • Active community support

Installation

Using Mapbox GL JS

yarn add react-map-gl mapbox-gl

For TypeScript projects:

yarn add react-map-gl mapbox-gl @types/mapbox-gl

Using MapLibre GL JS

yarn add react-map-gl maplibre-gl

Basic Setup

Mapbox GL JS Version

'use client';

import { useState } from 'react';
import Map from 'react-map-gl/mapbox';
import 'mapbox-gl/dist/mapbox-gl.css';

export default function MapComponent() {
  const [viewState, setViewState] = useState({
    longitude: -74.0060,
    latitude: 40.7128,
    zoom: 12
  });

  return (
    <Map
      {...viewState}
      onMove={evt => setViewState(evt.viewState)}
      mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
      style={{ width: '100%', height: '600px' }}
      mapStyle="mapbox://styles/mapbox/streets-v12"
    />
  );
}

MapLibre GL JS Version

'use client';

import { useState } from 'react';
import Map from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';

export default function MapComponent() {
  const [viewState, setViewState] = useState({
    longitude: -74.0060,
    latitude: 40.7128,
    zoom: 12
  });

  return (
    <Map
      {...viewState}
      onMove={evt => setViewState(evt.viewState)}
      style={{ width: '100%', height: '600px' }}
      mapStyle="https://demotiles.maplibre.org/style.json"
    />
  );
}

Core Concepts

State Management

React Map GL embraces React's controlled component pattern, where the map's state is managed by React:

Controlled Component (Recommended)

const [viewState, setViewState] = useState({
  longitude: -122.4,
  latitude: 37.8,
  zoom: 14,
  bearing: 0,
  pitch: 0
});

<Map
  {...viewState}
  onMove={evt => setViewState(evt.viewState)}
/>

Uncontrolled Component

<Map
  initialViewState={{
    longitude: -122.4,
    latitude: 37.8,
    zoom: 14
  }}
/>

Why Controlled Components?

  • Synchronize map with other UI elements
  • Implement custom navigation controls
  • Persist and restore map state
  • React to external state changes

Map Instance Access

Access the underlying Mapbox/MapLibre instance when needed:

import { useRef, useEffect } from 'react';
import { MapRef } from 'react-map-gl/mapbox';

export default function MapWithRef() {
  const mapRef = useRef<MapRef>(null);

  useEffect(() => {
    if (mapRef.current) {
      const map = mapRef.current.getMap();
      
      // Access native Mapbox GL methods
      map.on('load', () => {
        console.log('Map loaded');
      });
    }
  }, []);

  return (
    <Map
      ref={mapRef}
      initialViewState={{
        longitude: -122.4,
        latitude: 37.8,
        zoom: 14
      }}
      mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
      mapStyle="mapbox://styles/mapbox/streets-v12"
    />
  );
}

Adding Markers

Basic Marker

'use client';

import Map, { Marker } from 'react-map-gl/mapbox';
import 'mapbox-gl/dist/mapbox-gl.css';

export default function MapWithMarker() {
  return (
    <Map
      initialViewState={{
        longitude: -74.0060,
        latitude: 40.7128,
        zoom: 12
      }}
      style={{ width: '100%', height: '600px' }}
      mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
      mapStyle="mapbox://styles/mapbox/streets-v12"
    >
      <Marker
        longitude={-74.0060}
        latitude={40.7128}
        anchor="bottom"
      >
        <div style={{ 
          fontSize: '30px',
          cursor: 'pointer'
        }}>
          📍
        </div>
      </Marker>
    </Map>
  );
}

Multiple Markers from Data

'use client';

import { useState } from 'react';
import Map, { Marker, Popup } from 'react-map-gl/mapbox';
import 'mapbox-gl/dist/mapbox-gl.css';

interface Location {
  id: number;
  name: string;
  longitude: number;
  latitude: number;
}

const locations: Location[] = [
  { id: 1, name: 'Empire State Building', longitude: -73.9857, latitude: 40.7484 },
  { id: 2, name: 'Central Park', longitude: -73.9654, latitude: 40.7829 },
  { id: 3, name: 'Times Square', longitude: -73.9855, latitude: 40.7580 },
];

export default function MapWithMarkers() {
  const [popupInfo, setPopupInfo] = useState<Location | null>(null);

  return (
    <Map
      initialViewState={{
        longitude: -73.9857,
        latitude: 40.7484,
        zoom: 12
      }}
      style={{ width: '100%', height: '600px' }}
      mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
      mapStyle="mapbox://styles/mapbox/streets-v12"
    >
      {locations.map(location => (
        <Marker
          key={location.id}
          longitude={location.longitude}
          latitude={location.latitude}
          anchor="bottom"
          onClick={e => {
            e.originalEvent.stopPropagation();
            setPopupInfo(location);
          }}
        >
          <div style={{ 
            fontSize: '24px',
            cursor: 'pointer',
            transform: 'translate(-50%, -100%)'
          }}>
            📍
          </div>
        </Marker>
      ))}

      {popupInfo && (
        <Popup
          longitude={popupInfo.longitude}
          latitude={popupInfo.latitude}
          anchor="bottom"
          onClose={() => setPopupInfo(null)}
        >
          <div>
            <h3>{popupInfo.name}</h3>
            <p>
              Coordinates: {popupInfo.latitude.toFixed(4)}, {popupInfo.longitude.toFixed(4)}
            </p>
          </div>
        </Popup>
      )}
    </Map>
  );
}

Custom Marker Component

import { Marker } from 'react-map-gl/mapbox';

interface CustomMarkerProps {
  longitude: number;
  latitude: number;
  type: 'restaurant' | 'hotel' | 'attraction';
  onClick?: () => void;
}

const markerIcons = {
  restaurant: '🍴',
  hotel: '🏨',
  attraction: '🎭',
};

const markerColors = {
  restaurant: '#FF6B6B',
  hotel: '#4ECDC4',
  attraction: '#45B7D1',
};

export function CustomMarker({ longitude, latitude, type, onClick }: CustomMarkerProps) {
  return (
    <Marker
      longitude={longitude}
      latitude={latitude}
      anchor="bottom"
      onClick={onClick}
    >
      <div
        style={{
          backgroundColor: markerColors[type],
          borderRadius: '50% 50% 50% 0',
          width: '40px',
          height: '40px',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          fontSize: '20px',
          cursor: 'pointer',
          transform: 'rotate(-45deg)',
          boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
        }}
      >
        <span style={{ transform: 'rotate(45deg)' }}>
          {markerIcons[type]}
        </span>
      </div>
    </Marker>
  );
}

Built-in Controls

'use client';

import Map, {
  NavigationControl,
  GeolocateControl,
  FullscreenControl,
  ScaleControl
} from 'react-map-gl/mapbox';
import 'mapbox-gl/dist/mapbox-gl.css';

export default function MapWithControls() {
  return (
    <Map
      initialViewState={{
        longitude: -122.4,
        latitude: 37.8,
        zoom: 14
      }}
      style={{ width: '100%', height: '600px' }}
      mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
      mapStyle="mapbox://styles/mapbox/streets-v12"
    >
      {/* Navigation control (zoom and compass) */}
      <NavigationControl position="top-right" />
      
      {/* Geolocate control (find user location) */}
      <GeolocateControl
        position="top-right"
        trackUserLocation
        showUserHeading
      />
      
      {/* Fullscreen control */}
      <FullscreenControl position="top-right" />
      
      {/* Scale bar */}
      <ScaleControl
        position="bottom-left"
        unit="metric"
      />
    </Map>
  );
}

Working with Layers

GeoJSON Layer

'use client';

import Map, { Source, Layer, LayerProps } from 'react-map-gl/mapbox';
import 'mapbox-gl/dist/mapbox-gl.css';

const geojsonData = {
  type: 'FeatureCollection',
  features: [
    {
      type: 'Feature',
      geometry: {
        type: 'LineString',
        coordinates: [
          [-122.48, 37.83],
          [-122.47, 37.82],
          [-122.46, 37.81]
        ]
      },
      properties: {
        name: 'Sample Route'
      }
    }
  ]
};

const lineLayer: LayerProps = {
  id: 'route',
  type: 'line',
  paint: {
    'line-color': '#0080ff',
    'line-width': 4
  }
};

export default function MapWithGeoJSON() {
  return (
    <Map
      initialViewState={{
        longitude: -122.47,
        latitude: 37.82,
        zoom: 14
      }}
      style={{ width: '100%', height: '600px' }}
      mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
      mapStyle="mapbox://styles/mapbox/streets-v12"
    >
      <Source id="route-source" type="geojson" data={geojsonData}>
        <Layer {...lineLayer} />
      </Source>
    </Map>
  );
}

Map Events

Click Events

'use client';

import { useState } from 'react';
import Map, { Marker, MapLayerMouseEvent } from 'react-map-gl/mapbox';
import 'mapbox-gl/dist/mapbox-gl.css';

interface MarkerData {
  longitude: number;
  latitude: number;
  id: number;
}

export default function ClickableMap() {
  const [markers, setMarkers] = useState<MarkerData[]>([]);
  const [nextId, setNextId] = useState(1);

  const handleMapClick = (event: MapLayerMouseEvent) => {
    const { lng, lat } = event.lngLat;
    
    setMarkers(prev => [
      ...prev,
      {
        longitude: lng,
        latitude: lat,
        id: nextId
      }
    ]);
    
    setNextId(prev => prev + 1);
  };

  const removeMarker = (id: number) => {
    setMarkers(prev => prev.filter(m => m.id !== id));
  };

  return (
    <div>
      <div style={{ marginBottom: '10px' }}>
        <p>Click on the map to add markers. Click markers to remove them.</p>
        <button onClick={() => setMarkers([])}>Clear All Markers</button>
      </div>
      
      <Map
        initialViewState={{
          longitude: -122.4,
          latitude: 37.8,
          zoom: 12
        }}
        style={{ width: '100%', height: '600px' }}
        mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
        mapStyle="mapbox://styles/mapbox/streets-v12"
        onClick={handleMapClick}
      >
        {markers.map(marker => (
          <Marker
            key={marker.id}
            longitude={marker.longitude}
            latitude={marker.latitude}
            anchor="bottom"
            onClick={e => {
              e.originalEvent.stopPropagation();
              removeMarker(marker.id);
            }}
          >
            <div style={{ 
              fontSize: '24px',
              cursor: 'pointer'
            }}>
              📍
            </div>
          </Marker>
        ))}
      </Map>
    </div>
  );
}

Advanced Features

Clustering

'use client';

import { useState } from 'react';
import Map, { Source, Layer, LayerProps } from 'react-map-gl/mapbox';
import 'mapbox-gl/dist/mapbox-gl.css';

// Generate random points
const generatePoints = (count: number) => {
  return {
    type: 'FeatureCollection',
    features: Array.from({ length: count }, (_, i) => ({
      type: 'Feature',
      geometry: {
        type: 'Point',
        coordinates: [
          -122.4 + (Math.random() - 0.5) * 0.4,
          37.8 + (Math.random() - 0.5) * 0.4
        ]
      },
      properties: {
        id: i,
        name: `Location ${i + 1}`
      }
    }))
  };
};

export default function ClusteredMap() {
  const [data] = useState(generatePoints(500));

  const clusterLayer: LayerProps = {
    id: 'clusters',
    type: 'circle',
    source: 'points',
    filter: ['has', 'point_count'],
    paint: {
      'circle-color': [
        'step',
        ['get', 'point_count'],
        '#51bbd6',
        10,
        '#f1f075',
        50,
        '#f28cb1'
      ],
      'circle-radius': [
        'step',
        ['get', 'point_count'],
        20,
        10,
        30,
        50,
        40
      ]
    }
  };

  const clusterCountLayer: LayerProps = {
    id: 'cluster-count',
    type: 'symbol',
    source: 'points',
    filter: ['has', 'point_count'],
    layout: {
      'text-field': '{point_count_abbreviated}',
      'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
      'text-size': 12
    }
  };

  const unclusteredPointLayer: LayerProps = {
    id: 'unclustered-point',
    type: 'circle',
    source: 'points',
    filter: ['!', ['has', 'point_count']],
    paint: {
      'circle-color': '#11b4da',
      'circle-radius': 6,
      'circle-stroke-width': 2,
      'circle-stroke-color': '#fff'
    }
  };

  return (
    <Map
      initialViewState={{
        longitude: -122.4,
        latitude: 37.8,
        zoom: 10
      }}
      style={{ width: '100%', height: '600px' }}
      mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
      mapStyle="mapbox://styles/mapbox/streets-v12"
    >
      <Source
        id="points"
        type="geojson"
        data={data}
        cluster={true}
        clusterMaxZoom={14}
        clusterRadius={50}
      >
        <Layer {...clusterLayer} />
        <Layer {...clusterCountLayer} />
        <Layer {...unclusteredPointLayer} />
      </Source>
    </Map>
  );
}

Geocoding Integration

'use client';

import { useState } from 'react';
import Map, { Marker } from 'react-map-gl/mapbox';
import 'mapbox-gl/dist/mapbox-gl.css';

interface GeocodingResult {
  center: [number, number];
  place_name: string;
}

export default function GeocodingMap() {
  const [searchQuery, setSearchQuery] = useState('');
  const [results, setResults] = useState<GeocodingResult[]>([]);
  const [selectedLocation, setSelectedLocation] = useState<GeocodingResult | null>(null);
  const [viewState, setViewState] = useState({
    longitude: -122.4,
    latitude: 37.8,
    zoom: 12
  });

  const searchLocation = async () => {
    if (!searchQuery) return;

    const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN;
    const response = await fetch(
      `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(searchQuery)}.json?access_token=${token}`
    );
    
    const data = await response.json();
    setResults(data.features || []);
  };

  const selectLocation = (result: GeocodingResult) => {
    setSelectedLocation(result);
    setViewState({
      longitude: result.center[0],
      latitude: result.center[1],
      zoom: 14
    });
    setResults([]);
  };

  return (
    <div>
      <div style={{ marginBottom: '10px', display: 'flex', gap: '10px' }}>
        <input
          type="text"
          value={searchQuery}
          onChange={e => setSearchQuery(e.target.value)}
          onKeyPress={e => e.key === 'Enter' && searchLocation()}
          placeholder="Search for a location..."
          style={{
            flex: 1,
            padding: '8px',
            border: '1px solid #ccc',
            borderRadius: '4px'
          }}
        />
        <button onClick={searchLocation}>Search</button>
      </div>

      {results.length > 0 && (
        <div style={{
          marginBottom: '10px',
          backgroundColor: 'white',
          border: '1px solid #ccc',
          borderRadius: '4px',
          maxHeight: '200px',
          overflowY: 'auto'
        }}>
          {results.map((result, index) => (
            <div
              key={index}
              onClick={() => selectLocation(result)}
              style={{
                padding: '10px',
                cursor: 'pointer',
                borderBottom: '1px solid #eee'
              }}
              onMouseEnter={e => e.currentTarget.style.backgroundColor = '#f5f5f5'}
              onMouseLeave={e => e.currentTarget.style.backgroundColor = 'white'}
            >
              {result.place_name}
            </div>
          ))}
        </div>
      )}

      <Map
        {...viewState}
        onMove={evt => setViewState(evt.viewState)}
        style={{ width: '100%', height: '600px' }}
        mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
        mapStyle="mapbox://styles/mapbox/streets-v12"
      >
        {selectedLocation && (
          <Marker
            longitude={selectedLocation.center[0]}
            latitude={selectedLocation.center[1]}
            anchor="bottom"
          >
            <div style={{ fontSize: '30px' }}>📍</div>
          </Marker>
        )}
      </Map>
    </div>
  );
}

Performance Optimization

Memoization

import { useMemo } from 'react';
import Map, { Source, Layer } from 'react-map-gl/mapbox';

export default function OptimizedMap({ data }: { data: any[] }) {
  // Memoize expensive data transformations
  const geojsonData = useMemo(() => {
    return {
      type: 'FeatureCollection',
      features: data.map(item => ({
        type: 'Feature',
        geometry: {
          type: 'Point',
          coordinates: [item.lng, item.lat]
        },
        properties: item
      }))
    };
  }, [data]);

  // Memoize layer configuration
  const layer = useMemo(() => ({
    id: 'points',
    type: 'circle',
    paint: {
      'circle-radius': 6,
      'circle-color': '#0080ff'
    }
  }), []);

  return (
    <Map
      initialViewState={{
        longitude: -122.4,
        latitude: 37.8,
        zoom: 12
      }}
      mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
      mapStyle="mapbox://styles/mapbox/streets-v12"
    >
      <Source type="geojson" data={geojsonData}>
        <Layer {...layer} />
      </Source>
    </Map>
  );
}

Lazy Loading

// app/map-page/page.tsx
'use client';

import dynamic from 'next/dynamic';
import { Suspense } from 'react';

const MapComponent = dynamic(
  () => import('@/components/MapComponent'),
  {
    ssr: false,
    loading: () => (
      <div style={{
        width: '100%',
        height: '600px',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#f5f5f5'
      }}>
        Loading map...
      </div>
    )
  }
);

export default function MapPage() {
  return (
    <div>
      <h1>Interactive Map</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <MapComponent />
      </Suspense>
    </div>
  );
}

Best Practices

1. Always Use Client-Side Rendering

'use client';  // Required for Next.js App Router

2. Lazy Load Map Components

const Map = dynamic(() => import('./Map'), { ssr: false });

3. Secure API Tokens

NEXT_PUBLIC_MAPBOX_TOKEN=pk.your_token

4. Memoize Expensive Computations

const data = useMemo(() => transformData(rawData), [rawData]);

5. Clean Up Event Listeners

useEffect(() => {
  const map = mapRef.current?.getMap();
  const handler = () => { /* ... */ };
  
  map?.on('click', handler);
  return () => map?.off('click', handler);
}, []);

6. Handle Loading States

const [isMapLoaded, setIsMapLoaded] = useState(false);

<Map onLoad={() => setIsMapLoaded(true)} />

7. Implement Error Boundaries

<ErrorBoundary fallback={<MapErrorFallback />}>
  <Map />
</ErrorBoundary>

React Map GL provides a powerful, fully reactive solution for building interactive maps in React applications. Its declarative API, excellent performance, and support for both Mapbox and MapLibre make it an excellent choice for modern web mapping applications.