正直なところ、この問題の原因を特定するのに時間がかかりました。 地図をクリックしても何も起こらないという現象に直面したとき、最初は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のクリックイベントが無視される問題の解決方法) - [問題の概要:地図クリックが反応しない現象](#問題の概要地図クリックが反応しない現象) - [この改善の良かった点](#この改善の良かった点) - [実装コード](#実装コード) - [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`呼び出し後に`isReady`が`true`に変化することを確認 これにより、将来のコード変更で**回帰バグ**(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` 条件の強化**: `isReady` が `true` になるまでリスナー登録を遅延 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 ``` ### 設計上のポイント - `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を組み合わせる際に重宝します。 自プロジェクトでは、以下のような実装パターンとして応用できます: ```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. **初期状態**: `isReady`が`false`であること 2. **setCallback呼び出し後**: `isReady`が`true`に変化すること 3. **trigger実行時**: `isReady`が`false`の場合は呼び出しスキップ、`true`の場合は正常実行 4. **複数回setCallback**: 再設定しても`isReady`が`true`を維持すること これらを**単体テスト**で網羅することで、将来の回帰バグを防げます。 ---

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

地図をクリックしても何も起こらないという現象に直面したとき、最初は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を維持すること

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