【Next.js+Leaflet】無駄なレンダリングの回避方法

## はじめに
現在、Leafletを使用して地図上でクリックした際にテキスト入力欄が表示される仕組みを作っています。しかし、文字を入力するたびに地図が再レンダリングされてしまい、無駄なレンダリングが行われてしまっています。改善方法を備忘録として残しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2749225/2ca21065-f434-46ed-48e7-cba7b27d8d8a.png)

## 前提条件
1. Next14でプロジェクトが作成されていること
2. Leafletを初期状態としてWebブラウザ上に表示されていること
 
## 目次
1. 現在の実装方法
2. 改善案について

## 1. 現在の実装方法
以下のファイルでは、地図の表示と、formVisible の真偽値に基づいて投稿フォームの表示を実装しています。レンダリング処理の改善に関係しない部分については省略しています。

```typescript:LeafletMap.tsx

"use client";

import { LandData } from "@/utils/type";

import { useLeafletMap } from "@/hooks/useLeafletMap";

import { PrimaryButton } from "./parts/button/PrimaryButton";
import { SkeltonButton } from "./parts/button/SkeltonButton";

import "leaflet/dist/leaflet.css";

type Props = {
  land: LandData;
};

export const LeafletMap = (props: Props) => {
  const { mapRef, formVisible, latLng, content, setContent, setFormVisible } = useLeafletMap([33.5902, 130.4207], props.land);

  return (
    <>
      <div ref={mapRef} className="w-full h-[100vh]" />
      {formVisible && latLng && (
        <div className="absolute top-20 left-10 bg-white p-4 rounded shadow-md z-[3000]">
          <p className="font-semibold mb-3">この地点について起こったことを投稿してみんなに知らせよう！</p>
          <input value={content} onChange={(e) => setContent(e.target.value)} className="w-full border border-gray-300 p-2 rounded mb-5" placeholder="コメントを入力してください"></input>
          <div className="flex gap-3 items-center justify-end">
            <SkeltonButton onClick={() => setFormVisible(false)}>キャンセル</SkeltonButton>
            <PrimaryButton onClick={handleSubmit} disabled={!content}>
              {loading ? "送信中..." : "投稿"}
            </PrimaryButton>
          </div>
        </div>
      )}
    </>
  );
};
```

以下のファイルは、上記のコンポーネントの子コンポーネントであり、地図生成に関する具体的な処理が記述されています。
```typescript
"use client";

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

import { Coordinate, LandData } from "@/utils/type";

import L from "leaflet";
import "leaflet/dist/leaflet.css";

const DefaultIcon = L.icon({
  iconUrl: "/images/marker-icon.png",
  iconRetinaUrl: "/images/marker-icon.png",
  iconSize: [25, 41],
  iconAnchor: [12, 41],
  popupAnchor: [1, -34],
  tooltipAnchor: [16, -28],
});

L.Marker.prototype.options.icon = DefaultIcon;

export const useLeafletMap = (center: Coordinate, land?: LandData) => {
  const mapContainerRef = useRef<HTMLDivElement>(null);
  const mapRef = useRef<L.Map | null>(null);

  const [formVisible, setFormVisible] = useState(false);
  const [latLng, setLatLng] = useState<{ lat: number; lng: number } | null>(null);
  const [content, setContent] = useState("");

  useEffect(() => {
    if (typeof window !== "undefined" && mapContainerRef.current && !mapRef.current) {
      // マップ初期化処理
      const map = L.map(mapContainerRef.current).setView(center, 13);
      mapRef.current = map;

      L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
        attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
      }).addTo(map);

      // マップクリックイベント処理
      map.on("click", (e: L.LeafletMouseEvent) => {
        setLatLng({ lat: e.latlng.lat, lng: e.latlng.lng });
        setFormVisible(true);
      });
    }

    return () => {
      if (mapRef.current) {
        mapRef.current.remove();
        mapRef.current = null;
      }
    };
  }, [center, land]);

  return {
    mapRef: mapContainerRef,
    formVisible,
    latLng,
    setContent,
    content,
    setFormVisible,
  };
};
```

現在のコードでは、親コンポーネントから子コンポーネントに初期の座標（center）が渡されていますが、子コンポーネント内でその座標を保存していないため、文字入力を行うたびに子コンポーネントが再レンダリングされ、useEffectが再実行されます。その結果、地図の再生成が無駄に行われてしまっています。

## 2.　改善案
ではこの問題を解決するために、子コンポーネント内でcenter座標をuseStateで保持し、再レンダリング時でも座標が変更されない限りuseEffectを実行しないようにします。これにより、文字を入力しても地図が再生成されず、無駄なレンダリングを防ぐことができます。
<br>
実際にuseStateを使用して子コンポーネント内で座標（center）を保持するように変更してみます。

```typescript
"use client";

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

import { Coordinate, LandData } from "@/utils/type";

import L from "leaflet";
import "leaflet/dist/leaflet.css";

const DefaultIcon = L.icon({
  iconUrl: "/images/marker-icon.png",
  iconRetinaUrl: "/images/marker-icon.png",
  iconSize: [25, 41],
  iconAnchor: [12, 41],
  popupAnchor: [1, -34],
  tooltipAnchor: [16, -28],
});

L.Marker.prototype.options.icon = DefaultIcon;

export const useLeafletMap = (initialCenter: Coordinate, land?: LandData) => {
  const mapContainerRef = useRef<HTMLDivElement>(null);
  const mapRef = useRef<L.Map | null>(null);

  const [formVisible, setFormVisible] = useState(false);
  const [latLng, setLatLng] = useState<{ lat: number; lng: number } | null>(null);
  const [content, setContent] = useState("");
  const [center, setCenter] = useState<Coordinate>(initialCenter); //ステートとして保持させるように修正

  useEffect(() => {
    if (typeof window !== "undefined" && mapContainerRef.current && !mapRef.current) {
      // マップ初期化処理
      const map = L.map(mapContainerRef.current).setView(center, 13);
      mapRef.current = map;

      L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
        attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
      }).addTo(map);

      // マップクリックイベント処理
      map.on("click", (e: L.LeafletMouseEvent) => {
        setLatLng({ lat: e.latlng.lat, lng: e.latlng.lng });
        setFormVisible(true);
      });
    }

    return () => {
      if (mapRef.current) {
        mapRef.current.remove();
        mapRef.current = null;
      }
    };
  }, [center, land]);

  return {
    mapRef: mapContainerRef,
    formVisible,
    latLng,
    setContent,
    content,
    setFormVisible,
  };
};

```


これで再度文字を入力してみると、無駄なレンダリングが行われることはなくなりました！

## 終わりに
無駄なレンダリングを改善する際は、まず子コンポーネントで適切にステートを管理し、無駄なuseEffectが実行されていないかを確認しましょう。また、親コンポーネントから不要なデータが子コンポーネントに渡されていないかもチェックしましょう。

最後まで読んでいただき、ありがとうございました。