React hooks для Google Maps

Работа с картами в React требует строгого управления жизненным циклом компонентов, поскольку объект карты живёт вне React-дерева и напрямую взаимодействует с DOM и внешним API. При использовании Google Maps JavaScript API основная сложность заключается в синхронизации состояния React и императивного API картографической библиотеки.

Ключевая идея архитектуры — изолировать создание карты, управление слоями и подписки на события в кастомные hooks, минимизируя прямые побочные эффекты в компонентах.


Базовая загрузка API через hook

Перед созданием карты необходимо гарантировать загрузку внешнего скрипта Google Maps. В React это удобно реализуется через отдельный hook, отвечающий только за инъекцию script-тега и контроль готовности API.

useGoogleMapsScript

import { useEffect, useState } from "react";

export function useGoogleMapsScript(apiKey) {
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    if (window.google?.maps) {
      setLoaded(true);
      return;
    }

    const script = document.createElement("script");
    script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
    script.async = true;
    script.onl oad = () => setLoaded(true);

    document.head.appendChild(script);

    return () => {
      script.remove();
    };
  }, [apiKey]);

  return loaded;
}

Особенности реализации:

  • защита от повторной загрузки через проверку window.google.maps
  • управление жизненным циклом script-элемента
  • использование useEffect как единой точки побочного эффекта
  • отсутствие блокировки UI при загрузке

Инициализация карты через useRef и useEffect

Объект карты Google Maps нельзя хранить в state, поскольку его обновления не должны триггерить React-render. Для этого используется useRef.

useMap

import { useEffect, useRef, useState } from "react";

export function useMap(containerRef, options, isLoaded) {
  const mapRef = useRef(null);
  const [mapInstance, setMapInstance] = useState(null);

  useEffect(() => {
    if (!isLoaded || !containerRef.current || mapRef.current) return;

    mapRef.current = new window.google.maps.Map(containerRef.current, {
      center: options.center,
      zoom: options.zoom,
      disableDefaultUI: options.disableDefaultUI,
    });

    setMapInstance(mapRef.current);
  }, [isLoaded, containerRef, options]);

  return mapInstance;
}

Ключевые принципы:

  • useRef хранит императивный объект карты
  • useState используется только для передачи ссылки наружу
  • предотвращается повторная инициализация
  • карта создаётся строго один раз

Управление маркерами через hook

Маркер — типичный побочный объект, который должен синхронизироваться с React-данными.

useMarkers

import { useEffect, useRef } from "react";

export function useMarkers(map, markersData) {
  const markersRef = useRef([]);

  useEffect(() => {
    if (!map) return;

    markersRef.current.forEach(marker => marker.setMap(null));
    markersRef.current = [];

    markersData.forEach(item => {
      const marker = new window.google.maps.Marker({
        position: item.position,
        map,
        title: item.title,
      });

      markersRef.current.push(marker);
    });

    return () => {
      markersRef.current.forEach(marker => marker.setMap(null));
      markersRef.current = [];
    };
  }, [map, markersData]);
}

Особенности:

  • полная пересборка маркеров при изменении данных
  • очистка памяти через setMap(null)
  • хранение инстансов в useRef
  • отсутствие частичных обновлений для упрощения синхронизации

Работа с событиями карты

Google Maps использует собственную систему событий (addListener), не интегрированную с React synthetic events. Поэтому требуется ручное управление подписками.

useMapEvents

import { useEffect } from "react";

export function useMapEvents(map, handlers) {
  useEffect(() => {
    if (!map) return;

    const listeners = [];

    if (handlers.onClick) {
      listeners.push(
        map.addListener("click", handlers.onClick)
      );
    }

    if (handlers.onZoomChanged) {
      listeners.push(
        map.addListener("zoom_changed", handlers.onZoomChanged)
      );
    }

    return () => {
      listeners.forEach(listener =>
        window.google.maps.event.removeListener(listener)
      );
    };
  }, [map, handlers]);
}

Основные моменты:

  • подписки создаются только при наличии map
  • централизованная очистка listeners
  • предотвращение утечек памяти
  • изоляция логики событий

Композиция hooks в компоненте Map

import React, { useRef } from "react";
import { useGoogleMapsScript } from "./useGoogleMapsScript";
import { useMap } from "./useMap";
import { useMarkers } from "./useMarkers";
import { useMapEvents } from "./useMapEvents";

export function Map({ apiKey, markers }) {
  const containerRef = useRef(null);

  const isLoaded = useGoogleMapsScript(apiKey);

  const map = useMap(
    containerRef,
    {
      center: { lat: 40.7128, lng: -74.006 },
      zoom: 10,
      disableDefaultUI: false,
    },
    isLoaded
  );

  useMarkers(map, markers);

  useMapEvents(map, {
    onClick: (e) => {
      console.log(e.latLng.toJSON());
    },
  });

  return <div ref={containerRef} style={{ width: "100%", height: "500px" }} />;
}

Архитектурные свойства:

  • декларативное описание карты
  • разделение ответственности hooks
  • отсутствие прямых мутаций DOM вне контейнера
  • реактивное управление данными маркеров

Контекст для глобального доступа к карте

При усложнении приложения требуется доступ к объекту карты из разных компонентов. Для этого используется Context API.

MapContext

import { createContext, useContext } from "react";

export const MapContext = createContext(null);

export function useMapContext() {
  return useContext(MapContext);
}

Provider

export function MapProvider({ map, children }) {
  return (
    <MapContext.Provider value={map}>
      {children}
    </MapContext.Provider>
  );
}

Преимущества:

  • устранение prop drilling
  • единая точка доступа к карте
  • возможность добавления слоёв, контролов и сервисов поверх карты

Динамическое обновление центра и zoom

Синхронизация React state с состоянием карты требует аккуратного контроля, чтобы избежать бесконечных циклов обновления.

import { useEffect } from "react";

export function useMapViewSync(map, center, zoom) {
  useEffect(() => {
    if (!map) return;

    map.setCenter(center);
  }, [map, center]);

  useEffect(() => {
    if (!map) return;

    map.setZoom(zoom);
  }, [map, zoom]);
}

Принципы:

  • разделение эффектов по сущностям
  • отсутствие обратной синхронизации без необходимости
  • прямое управление через imperative API

Работа с InfoWindow через hook

InfoWindow — типичный пример объекта, который должен существовать в единственном экземпляре.

import { useRef } from "react";

export function useInfoWindow(map) {
  const infoWindowRef = useRef(null);

  if (!infoWindowRef.current && window.google?.maps) {
    infoWindowRef.current = new window.google.maps.InfoWindow();
  }

  const open = (marker, content) => {
    if (!infoWindowRef.current) return;

    infoWindowRef.current.setContent(content);
    infoWindowRef.current.open(map, marker);
  };

  const close = () => {
    infoWindowRef.current?.close();
  };

  return { open, close };
}

Характеристики:

  • singleton-объект
  • отсутствие React state
  • прямое управление API Google Maps

Производительность и стабилизация ссылок

При работе с картами критично избегать лишних пересозданий объектов.

Основные техники:

  • useRef для хранения инстансов
  • useMemo для опций карты
  • стабилизация callback через useCallback
  • минимизация зависимостей в useEffect

Пример стабилизации опций:

const mapOptions = useMemo(() => ({
  center,
  zoom,
  disableDefaultUI: true,
}), [center.lat, center.lng, zoom]);

Lazy loading и code splitting

Загрузка карты должна происходить только при необходимости, особенно в SPA и Next.js приложениях.

import dynamic from "next/dynamic";

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

Это предотвращает:

  • ошибки SSR (window is not defined)
  • загрузку тяжелого API до необходимости
  • блокировку первичного рендера

Расширяемость через дополнительные hooks

usePlacesAutocomplete

Интеграция с Places API часто выделяется в отдельный hook, работающий поверх Google Maps API:

  • управление запросами автодополнения
  • дебаунс ввода
  • кэширование результатов
  • синхронизация с картой

useHeatmap

Для тепловых карт:

  • управление слоем google.maps.visualization.HeatmapLayer
  • реактивное обновление данных
  • переключение видимости слоя

Архитектурная модель hooks-слоя для карт

Типовая структура:

  • useGoogleMapsScript — загрузка API
  • useMap — создание карты
  • useMarkers — управление точками
  • useMapEvents — события
  • useMapViewSync — синхронизация состояния
  • useInfoWindow — всплывающие окна
  • usePlacesAutocomplete — поиск

Такая декомпозиция формирует слой абстракции над императивным API, делая карту частью React-архитектуры без потери контроля над производительностью и жизненным циклом объектов.