正直なところ、この問題の原因を特定するのに時間がかかりました。
地図をクリックしても何も起こらないという現象に直面したとき、最初はGoogle Maps APIの設定ミスを疑いました。
実際には、Reactの useEffect の初期化順序による競合状態(race condition)が原因でした。
この記事では、カスタムフック useRefCallback に isReady 状態を追加して、この問題を解決した方法を解説します。
注意:
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呼び出し後にisReadyがtrueに変化することを確認
これにより、将来のコード変更で回帰バグ(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状態の導入
isReady状態の導入:setCallback()が呼ばれるまでfalseenabled条件の強化:isReadyがtrueになるまでリスナー登録を遅延- ドキュメント強化:
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
設計上のポイント
isReadyはuseStateで管理(再レンダリングをトリガーするため)setCallbackはuseCallbackでメモ化(依存配列の安定性確保)- テストで 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: 技術的には可能ですが、推奨しません。理由は以下の通りです:
- 競合の根本原因は解決されない(イベント発火時にnullチェックが走るため)
- エッジケースでクリックが無効化されるリスクが残る
- テストで正確な動作保証がしづらい
isReady状態は「準備完了までイベント登録を遅延する」という明確な意図をコードに伝えるため、チーム開発でも理解しやすくなります。
Q3: useStateではなくuseRefを使うことにデメリットはありますか?
A: はい、useRefには重要な制約があります。useRefの値変更は再レンダリングをトリガーしないため、以下の問題が発生します:
enabled条件の評価がタイミングによって古いままになる- イベントリスナーの登録が意図したタイミングで行われない可能性がある
したがって、isReady状態の変更を即座にコンポーネントに反映させたい場合は、useStateを使うべきです。
Q4: 同様のパターンを他のイベント処理にも適用できますか?
A: はい、このパターンは汎用的です。以下のような場面で応用できます:
- フォームの送信ハンドラー(バリデーション完了まで送信ボタンを無効化)
- WebSocketのイベントハンドラー(接続確立までイベント受信を遅延)
- MediaRecorderのコールバック(デバイス権限取得まで録画を遅延)
isReadyフラグの考え方は、あらゆる非同期初期化シーンに適用可能です。
Q5: テストでisReadyの状態遷移を網羅するにはどうすれば良いですか?
A: 以下のテストパターンを推奨します:
- 初期状態:
isReadyがfalseであること - setCallback呼び出し後:
isReadyがtrueに変化すること - trigger実行時:
isReadyがfalseの場合は呼び出しスキップ、trueの場合は正常実行 - 複数回setCallback: 再設定しても
isReadyがtrueを維持すること
これらを単体テストで網羅することで、将来の回帰バグを防げます。


