正直なところ、この問題の原因を特定するのに時間がかかりました。
地図をクリックしても何も起こらないという現象に直面したとき、最初は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)が原因でした。
この記事では、カスタムフック 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() が呼ばれるまで false
enabled 条件の強化: 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を維持すること
これらを単体テストで網羅することで、将来の回帰バグを防げます。