正直なところ、この問題の原因を特定するのに時間がかかりました。

地図をクリックしても何も起こらないという現象に直面したとき、最初はGoogle Maps APIの設定ミスを疑いました。

実際には、Reactの useEffect の初期化順序による競合状態(race condition)が原因でした。

この記事では、カスタムフック useRefCallbackisReady 状態を追加して、この問題を解決した方法を解説します。

注意: useRefCallback はReactの標準Hook(useStateやuseEffectなど)ではなく、このプロジェクトで独自に実装したカスタムフックです。一般的には「遅延コールバックフック(Deferred Callback Hook)」や「初期化完了を待機するイベントハンドラー」と呼ばれるパターンの一例としてお読みください。自プロジェクトでも同様のパターンを実装する際の参考になれば幸いです。

【目次の目安:この記事は約5分で読めます】

こんな人におすすめ

  • ReactアプリケーションでGoogle Mapsや外部ライブラリのイベント処理を実装している方
  • 競合状態(race condition)の問題に直面して、解決策を探しているエンジニア
  • React Hooks(useEffect, useState, useCallback)の使い分けに不安がある方
  • カスタムフックの設計やテスト方法に興味がある方
  • イベントハンドラーが初期化時に正しく動作しない問題で悩んでいる方

目次


ReactでGoogle Mapsのクリックイベントが無視される問題の解決方法

React アプリケーションで Google Maps のクリックイベントハンドラーに競合状態(race condition)が発生していた問題を修正しました。コールバック関数の設定完了前にイベントリスナーが登録されてしまい、クリックイベントが無視される現象を解消できます。

解決策として、プロジェクト固有のカスタムフック useRefCallback(遅延コールバックフック)に isReady 状態を追加し、コールバック設定が完了するまでリスナー登録を遅延させる設計に変更します。

問題の概要:地図クリックが反応しない現象

開発中のReactアプリケーションで、以下の症状に遭遇しました:

  • Google Maps上でクリック操作をしても、何も反応がない
  • 同じコードでも、リロードすると正常に動作することがある
  • イベントリスナーは登録されているはずなのに、コールバック関数が呼ばれない

正直なところ、最初はGoogle Maps APIのバグや設定ミスを疑っていましたが、実際には自前のReactコードの初期化順序に問題がありました。

これは典型的な 競合状態(race condition) の事例です。非同期処理や副作用(side effects)のタイミングが偶発的に一致しないと、問題が再現しないため、デバッグが非常に困難です。

この記事では、私が実際に2〜3時間を費やして解決した方法を、具体的なコード例と共に解説します。

この改善の良かった点

実際にこの修正を適用してみて、いくつかの利点がありました。

競合状態を完全に解消でき、安定したイベント処理が実現できました

それまで不安定だった地図クリックが、毎回確実に動作するようになりました。

ユーザー体験の観点からも、これは大きな改善です。「クリックしても反応しない」というフラストレーションが完全に解消されました。

isReady 状態という明確なフラグで、コードの意図が伝わりやすくなりました

チームメンバーも「ああ、コールバックが設定されるまで待つんだね」とすぐに理解してくれました。

コードレビュー時にも、「このフラグは何のため?」という質問が減り、レビュー効率が向上しました。

テスト可能な設計になり、回帰テストも容易になりました

isReady の状態遷移をテストで網羅できるようになり、将来的なバグ予防にもつながっています。

JestやReact Testing Libraryを使った単体テストで、isReadyの初期値、setCallback呼び出し後の変化、trigger実行時の挙動を網羅的にテストできるようになりました。

実装コード

src/hooks/useRefCallback.ts

import { useRef, useCallback, useState } from 'react';

/**
 * デファード・コールバックフック(競合状態防止用)
 *
 * コールバックを後から設定し、設定完了まで呼び出しを遅延させるためのフック。
 * 地図クリックハンドラーなど、初期化タイミングで競合状態が発生する場面で使用。
 *
 * @important `setCallback()` の呼び出しは必須です。
 * 呼び忘れると `isReady` が false のままとなり、
 * リスナーが永続的に無効化されます。
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useRefCallback<T extends (...args: any[]) => void>(): {
  trigger: (...args: Parameters<T>) => void;
  setCallback: (callback: T) => void;
  ref: React.MutableRefObject<T | null>;
  /** コールバックが設定済みかどうか(競合状態防止用) */
  isReady: boolean;
} {
  const callbackRef = useRef<T | null>(null);
  const [isReady, setIsReady] = useState(false);

  // ... trigger実装 ...

  const setCallback = useCallback((callback: T) => {
    callbackRef.current = callback;
    setIsReady(true);
  }, []);

  return { trigger, setCallback, ref: callbackRef, isReady };
}

実装のポイント

  • useStateをインポートして、isReady状態を管理
  • setCallbackが呼ばれたタイミングでsetIsReady(true)を実行
  • 返り値にisReadyを追加して、外部から状態を参照可能に

この設計により、カスタムフックの利用者は「コールバック設定の完了」を明確に検知できるようになりました。

src/App.tsx

useMapClickHandler({
  mapInstance: googleMaps.mapInstance,
  enabled:
    googleMaps.isLoaded &&
    currentView === 'search' &&
    searchSingleClick.isReady &&
    searchDoubleClick.isReady,
  mapVersion: googleMaps.mapVersion,
  onSingleClick: searchSingleClick.trigger,
  onDoubleClick: searchDoubleClick.trigger,
});

適用のポイント

  • enabled条件にisReadyチェックを追加
  • 単一クリックダブルクリックの両方が準備完了になるまで、イベントリスナーの登録を遅延

この変更により、コールバック関数がnullの状態でイベントリスナーが登録される事態を防げます。

src/hooks/tests/useRefCallback.test.ts(抜粋)

describe('isReady 状態(競合状態防止用)', () => {
  it('初期状態で isReady が false であること', () => {
    const { result } = renderHook(() => useRefCallback<() => void>());
    expect(result.current.isReady).toBe(false);
  });

  it('setCallback 後に isReady が true になること', () => {
    const { result } = renderHook(() => useRefCallback<() => void>());
    const mockCallback = jest.fn();

    act(() => {
      result.current.setCallback(mockCallback);
    });

    expect(result.current.isReady).toBe(true);
  });
});

テストのポイント

  • isReadyの初期値がfalseであることを確認
  • setCallback呼び出し後にisReadytrueに変化することを確認

これにより、将来のコード変更で回帰バグ(regression)が発生した際も、テストが検知してくれます。

得られた効果

この修正により、いくつかの具体的な改善が見られました。

ユーザー体験の向上

地図クリックが確実に動作するようになり、ユーザーからの「クリックしても反応しない」といった報告がなくなりました。

デバッグ時間の削減

競合状態という不確定な動作が解消されたことで、開発中のデバッグ時間が大幅に短縮されました。

個人的には、この問題に2〜3時間を費やしましたが、isReady 状態を導入してからは同様の問題に遭遇していません。

コードの保守性向上

明確な状態管理フラグにより、新しいメンバーがコードを理解しやすくなりました。

補足

問題の根本原因:useEffectの初期化順序

Reactの useEffect でコールバックを設定する前に、別の useEffect がイベントリスナーを登録してしまう初期化順序の問題。コールバック参照が null の状態でクリックイベントが発火すると、何も起こらないという症状が発生していた。

この問題は、Reactのマウントプロセスと**副作用(side effects)**の実行タイミングに関連しています。

以下のフローチャートで、問題が発生する流れを視覚化しました。

flowchart TD
    A[コンポーネントマウント] --> B[useEffect1: リスナー登録開始]
    A --> C[useEffect2: コールバック設定開始]

    B --> D{コールバックはnull?}
    D -->|はい| E[クリックイベントが無視される]
    D -->|いいえ| F[正常に処理]

    C --> G[setCallback呼び出し]
    G --> H[コールバック設定完了]

    style E fill:#ffcccc
    style F fill:#ccffcc

さらに、シーケンス図で時系列的なタイミングのズレを見てみましょう。

sequenceDiagram
    participant User as ユーザー
    participant Comp as Reactコンポーネント
    participant UE1 as useEffect1<br/>(リスナー登録)
    participant UE2 as useEffect2<br/>(コールバック設定)
    participant Maps as Google Maps

    Comp->>UE1: マウント: リスナー登録開始
    Comp->>UE2: マウント: コールバック設定開始

    UE1->>Maps: addListener('click', callbackRef.current)
    Note over UE1,Maps: ⚠️ callbackRef.currentはまだnull

    User->>Maps: 地図をクリック
    Maps->>UE1: クリックイベント発火
    UE1->>UE1: callbackRef.current? → null
    Note over UE1: ❌ イベントが無視される

    UE2->>UE2: setCallback(callback)
    UE2->>UE2: setIsReady(true)
    Note over UE2: ✅ コールバック設定完了(タイミング遅い)

この問題で困った経験はありませんか?
私は最初、Google Maps APIのバグを疑ってしまいましたが、実際には自前のReactコードに問題がありました。

React Hooksの依存配列や実行順序を正しく理解することが、この種の問題を防ぐ鍵となります。

解決アプローチ:isReady状態の導入

  1. isReady 状態の導入: setCallback() が呼ばれるまで false
  2. enabled 条件の強化: isReadytrue になるまでリスナー登録を遅延
  3. ドキュメント強化: setCallback() 呼び忘れ防止のためのJSDoc追加

isReadyの状態遷移を図で見てみましょう。

stateDiagram-v2
    [*] --> 未初期化: フック呼び出し
    未初期化: isReady = false
    未初期化 --> 準備完了: setCallback()
    準備完了: isReady = true
    準備完了 --> 準備完了: trigger()実行可能
    準備完了 --> [*]: コンポーネントアンマウント

    note right of 未初期化
        コールバック未設定
        イベント無効
    end note

    note right of 準備完了
        コールバック設定済み
        イベント有効
    end note

このアプローチの利点は以下の通りです:

  • 明示的な状態管理: isReadyフラグで「準備完了」を明確に表現
  • テスト容易性: 状態遷移を単体テストで検証可能
  • 再利用性: 他のカスタムフックやイベント処理にも適用可能

修正前後のフローを並べて比較してみましょう。

flowchart LR
    subgraph Before [修正前:問題が発生]
        B1[コンポーネントマウント] --> B2[useEffect実行]
        B2 --> B3{クリックイベント}
        B3 -->|callback=null| B4[❌ 無視される]
        B3 -->|callback≠null| B5[✅ 正常処理]
    end

    subgraph After [修正後:isReadyで遅延]
        A1[コンポーネントマウント] --> A2[useEffect実行]
        A2 --> A3{isReady?}
        A3 -->|false| A4[⏳ リスナー登録待機]
        A3 -->|true| A5[✅ リスナー登録]
        A4 --> A6[setCallback呼び出し]
        A6 --> A7[isReady=trueに変更]
        A7 --> A5
        A5 --> A8{クリックイベント}
        A8 --> A9[✅ 常に正常処理]
    end

    style B4 fill:#ffcccc
    style A4 fill:#fff4cc
    style A5 fill:#ccffcc
    style A9 fill:#ccffcc

設計上のポイント

  • isReadyuseState で管理(再レンダリングをトリガーするため)
  • setCallbackuseCallback でメモ化(依存配列の安定性確保)
  • テストで enabled フラグの遷移パターンを網羅

useState vs useRef:

  • useStateは状態変化時に再レンダリングをトリガーするため、isReadyの変化を即座にUIに反映できます
  • useRefは再レンダリングをトリガーしないため、イベントリスナーの登録タイミング制御には不向きでした

まとめ

この記事では、ReactアプリケーションでGoogle Mapsのクリックイベントハンドラーに発生していた競合状態の問題を解決する方法を解説しました。

学びのポイント:

  • useEffect の初期化順序による競合状態を、isReady 状態フラグで解決できる
  • カスタムフックに状態管理を追加することで、明確な初期化プロセスを実現できる
  • テスト可能な設計にすることで、回帰テストと保守性が向上する

正直なところ、競合状態のデバッグは難しいです。
しかし、isReady のような明確なフラグを導入することで、問題の原因を特定しやすくなりました。

同様の問題で悩んでいる方の参考になれば幸いです。

よくある質問(FAQ)

Q1: useRefCallbackフックはどのような場面で使うべきですか?

A: まず前提として、useRefCallback はこのプロジェクトで実装したカスタムフック(一般的には「遅延コールバックフック」と呼ばれるパターン)です。Reactの標準Hookではありません。

このパターンは、コールバック関数を「後から設定」する必要がある場面で有効です。具体的には以下のようなケースが挙げられます:

  • 外部ライブラリの初期化が完了してからイベントハンドラーを設定したい場合
  • 親コンポーネントからのコールバック受け渡しが非同期に行われる場合
  • 条件付きレンダリングで、イベントリスナーの登録を遅延させたい場合

特にGoogle Maps、Chart.js、D3.jsなどの外部ライブラリとReactを組み合わせる際に重宝します。

自プロジェクトでは、以下のような実装パターンとして応用できます:

// 一般的な実装例
function useDeferredCallback<T extends (...args: any[]) => void>() {
  const callbackRef = useRef<T | null>(null);
  const [isReady, setIsReady] = useState(false);

  const setCallback = useCallback((callback: T) => {
    callbackRef.current = callback;
    setIsReady(true);
  }, []);

  const trigger = useCallback((...args: Parameters<T>) => {
    if (callbackRef.current) {
      callbackRef.current(...args);
    }
  }, []);

  return { trigger, setCallback, isReady };
}

Q2: isReady状態を使わずに、コールバックのnullチェックだけで解決できませんか?

A: 技術的には可能ですが、推奨しません。理由は以下の通りです:

  1. 競合の根本原因は解決されない(イベント発火時にnullチェックが走るため)
  2. エッジケースでクリックが無効化されるリスクが残る
  3. テストで正確な動作保証がしづらい

isReady状態は「準備完了までイベント登録を遅延する」という明確な意図をコードに伝えるため、チーム開発でも理解しやすくなります。

Q3: useStateではなくuseRefを使うことにデメリットはありますか?

A: はい、useRefには重要な制約があります。useRefの値変更は再レンダリングをトリガーしないため、以下の問題が発生します:

  1. enabled条件の評価がタイミングによって古いままになる
  2. イベントリスナーの登録が意図したタイミングで行われない可能性がある

したがって、isReady状態の変更を即座にコンポーネントに反映させたい場合は、useStateを使うべきです。

Q4: 同様のパターンを他のイベント処理にも適用できますか?

A: はい、このパターンは汎用的です。以下のような場面で応用できます:

  • フォームの送信ハンドラー(バリデーション完了まで送信ボタンを無効化)
  • WebSocketのイベントハンドラー(接続確立までイベント受信を遅延)
  • MediaRecorderのコールバック(デバイス権限取得まで録画を遅延)

isReadyフラグの考え方は、あらゆる非同期初期化シーンに適用可能です。

Q5: テストでisReadyの状態遷移を網羅するにはどうすれば良いですか?

A: 以下のテストパターンを推奨します:

  1. 初期状態: isReadyfalseであること
  2. setCallback呼び出し後: isReadytrueに変化すること
  3. trigger実行時: isReadyfalseの場合は呼び出しスキップ、trueの場合は正常実行
  4. 複数回setCallback: 再設定してもisReadytrueを維持すること

これらを単体テストで網羅することで、将来の回帰バグを防げます。