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

地図をクリックしても何も起こらないという現象に直面したとき、最初は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のクリックイベントが無視される問題の解決方法)
- [問題の概要:地図クリックが反応しない現象](#問題の概要地図クリックが反応しない現象)
- [この改善の良かった点](#この改善の良かった点)
- [実装コード](#実装コード)
- [src/hooks/useRefCallback.ts](#srchooksuse-refcallbackts)
- [src/App.tsx](#srcapptsx)
- [src/hooks/__tests__/useRefCallback.test.ts(抜粋)](#srchooks__tests__use-refcallbacktestts抜粋)
- [得られた効果](#得られた効果)
- [補足](#補足)
- [問題の根本原因:useEffectの初期化順序](#問題の根本原因useeffectの初期化順序)
- [解決アプローチ:isReady状態の導入](#解決アプローチisready状態の導入)
- [設計上のポイント](#設計上のポイント)
- [まとめ](#まとめ)
- [よくある質問(FAQ)](#よくある質問faq)
- [関連記事](#関連記事)

---

## 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

``typescript
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

`typescript
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(抜粋)

`typescript
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)**の実行タイミングに関連しています。

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

`mermaid
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
`

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

`mermaid
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の状態遷移を図で見てみましょう。

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

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

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

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

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

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

`mermaid
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を組み合わせる際に重宝します。

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

`typescript
// 一般的な実装例
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`を維持すること

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

---