本記事はFIXERが提供する「cloud.config Tech Blog」に掲載された「Expo + React Native で FCM プッシュ通知を簡単に実装してみる」を再編集したものです。
はじめに
「モバイルアプリにプッシュ通知を入れたいけど、なんか難しそう…」と思っていませんか? 自分も最初はそう思ってました。iOSとAndroidで設定が違うし、Expoとの相性も気になるし、そもそもFirebaseの設定がめんどくさそう、と。
もちろんプロダクション品質にするにはもうちょっと手を入れる必要がありますが、「まず動くものを作る」というところまでなら意外とシンプルです。
この記事では、Expo(React Native)プロジェクトに Firebase Cloud Messaging(FCM)でプッシュ通知を組み込んだ手順を、「簡単にやってみる」目線で紹介します。
この記事の前提
・Expo(SDK 50+)を使ったReact Nativeプロジェクトがすでにある
・Firebaseプロジェクトは作成済み(まだの人はFirebase Consoleから作れます)
まずは全体像をつかもう
いきなりコードを書く前に、プッシュ通知がどう流れるのかをざっくり見ておきましょう。 全体像がわかっていると、「今どの部分を実装しているのか」がわかりやすくなります。
この記事ではモバイルアプリ側(1〜3)にフォーカスします。 バックエンド側は「FCMトークンを受け取って Firebase Admin SDK で送る」というシンプルな構成なので、詳細は割愛します。
Step 1: ライブラリを入れる
1パッケージのインストール
必要なのはこの2つだけです。
npx expo install @react-native-firebase/app @react-native-firebase/messaging
@react-native-firebase/app がFirebaseの基盤で、@react-
native-firebase/messaging がFCM本体です。 npx expo install を使えば、Expoのバージョンに合ったものを自動で入れてくれるので楽ちんです。
Step 2: Firebase設定ファイルを配置する
Firebase Consoleでアプリを登録すると、プラットフォームごとの設定ファイルがダウンロードできます。 これをプロジェクトの所定の場所に置きましょう。
iOS
・GoogleService-Info.plist
・配置先: ios/ 直下
Android
・google-services.json
・配置先: android/app/ 直下
Step 3: app.json を設定する
ここがExpoならではのポイント。app.json(または app.config.js)にFirebase関連の設定を追加します。
{
"expo": {
"ios": {
"infoPlist": {
"UIBackgroundModes": ["remote-notification"]
},
"googleServicesFile": "./ios/GoogleService-Info.plist",
"useFrameworks": "static"
},
"android": {
"permissions": ["android.permission.POST_NOTIFICATIONS"],
"googleServicesFile": "./android/app/google-services.json"
},
"plugins": [
[
"@react-native-firebase/app",
{
"ios": { "useFrameworks": "static" }
}
],
"@react-native-firebase/messaging"
]
}
}
ハマりポイント: useFrameworks
iOSでは "useFrameworks": "static" を忘れると、ビルド時にFirebase関連のPodリンクが失敗します。 自分もこれで最初ハマりました。Firebaseのドキュメントにもしれっと書いてありますが、見落としがちなので注意です。
Step 4: Expo Go で動かない問題に対処する
ここ、地味に大事です。Firebase Messaging はネイティブモジュールなので、Expo Go では動きません。 開発中にExpo Goで起動するとクラッシュしてしまうので、環境を判定して回避する仕組みが必要です。
// utils/platformDetection.ts
import Constants from 'expo-constants';
// Expo Go 環境かどうかを判定
export const isExpoGo = (): boolean =>
Constants.appOwnership === 'expo';
// Firebase Messaging が使える環境かどうか
export const isFirebaseMessagingAvailable = (): boolean =>
!isExpoGo();
たった数行ですが、この判定がないと開発時にかなり困ります。 Expo Go で普段の開発をしつつ、通知のテストだけ開発ビルドで確認する、というフローが実現できます。
Step 5: FCMトークンを取得するHookを作る
ここからが本丸。FCMトークンを取得するカスタムHookを作ります。 このトークンが「このデバイスに通知を送ってね」という宛先になります。
// hooks/notification/useFcmToken.ts
import { useEffect, useState, useRef, useCallback } from 'react';
import { isFirebaseMessagingAvailable } from '@/utils/platformDetection';
// Expo Go ではインポートしない(条件付きrequire)
let messaging: any = null;
if (isFirebaseMessagingAvailable()) {
try {
messaging = require('@react-native-firebase/messaging').default;
} catch (error) {
console.warn('Firebase Messaging is not available:', error);
}
}
export const useFcmToken = () => {
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const isInitialized = useRef(false);
// パーミッション要求 → トークン取得
const requestPermission = useCallback(async () => {
if (!messaging) return false;
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (enabled) {
const fcmToken = await messaging().getToken();
if (fcmToken) setToken(fcmToken);
}
return enabled;
}, []);
// 初期化(アプリ起動時に1回だけ実行)
useEffect(() => {
if (isInitialized.current || !messaging) {
setIsLoading(false);
return;
}
isInitialized.current = true;
requestPermission().finally(() => setIsLoading(false));
// トークン更新リスナー
const unsubscribe = messaging().onTokenRefresh((newToken: string) => {
setToken(newToken);
});
return unsubscribe;
}, [requestPermission]);
return { token, isLoading, requestPermission };
};
条件付き require がポイント
import 文だとバンドル時に必ず読み込まれてしまうので、require() でランタイムに条件付きで読み込んでいます。 これにより Expo Go 環境ではFirebaseモジュールを一切ロードしないようにしています。
React Strict Mode の罠
React 18 の Strict Mode では useEffect が開発時に2回実行されます。 useRef で初期化フラグを持たせることで、トークン取得が2重に走るのを防いでいます。
Step 6: Context でトークンをアプリ全体に共有する
取得したFCMトークンは、テスト通知の送信やバックエンドへの登録など、いろんな場所から使いたくなります。 React Context で包むのが手っ取り早いです。
// contexts/FcmContext.tsx
const FcmContext = createContext<FcmTokenState | null>(null);
export const FcmProvider = ({ children }) => {
const fcmToken = useFcmToken();
return (
<FcmContext.Provider value={fcmToken}>
{children}
</FcmContext.Provider>
);
};
// どのコンポーネントからでもトークンにアクセスできる
export const useFcm = () => {
const context = useContext(FcmContext);
if (!context) {
throw new Error('useFcm must be used within FcmProvider');
}
return context;
};
あとはルートレイアウト(_layout.tsx)で <FcmProvider> を他のProviderより外側に配置するだけです。
// app/_layout.tsx
// ① フォアグラウンド:自分でアラートを出す
useEffect(() => {
if (!messaging) return;
const unsubscribe = messaging().onMessage(async (remoteMessage) => {
const { notification } = remoteMessage;
if (notification) {
Alert.alert(
notification.title || 'お知らせ',
notification.body || ''
);
}
});
return unsubscribe;
}, []);
// ② バックグラウンドから通知タップで復帰
useEffect(() => {
if (!messaging) return;
const unsubscribe = messaging().onNotificationOpenedApp((remoteMessage) => {
console.log('通知タップで起動:', remoteMessage);
// → 特定の画面に遷移するなどの処理
});
return unsubscribe;
}, []);
// ③ 完全終了状態から通知タップで起動
useEffect(() => {
if (!messaging) return;
messaging().getInitialNotification().then((remoteMessage) => {
if (remoteMessage) {
console.log('kill状態から通知で起動:', remoteMessage);
}
});
}, []);
Step 7: 通知を受け取る処理を書く
プッシュ通知は、アプリの状態によって受信方法が違います。ここが一番ややこしいところですが、整理するとこの3パターンです。
| アプリの状態 | 使うAPI | 何が起きるか |
| フォアグラウンド(開いてる) | onMessage | 自前で表示する必要あり |
| バックグラウンド(閉じてる) | onNotificationOpenedApp | 通知タップで起動した時に処理 |
| 完全終了(killされてる) | getInitialNotification | 通知タップで起動した時に処理 |
それぞれの実装を見てみましょう。
// app/_layout.tsx
// ① フォアグラウンド:自分でアラートを出す
useEffect(() => {
if (!messaging) return;
const unsubscribe = messaging().onMessage(async (remoteMessage) => {
const { notification } = remoteMessage;
if (notification) {
Alert.alert(
notification.title || 'お知らせ',
notification.body || ''
);
}
});
return unsubscribe;
}, []);
// ② バックグラウンドから通知タップで復帰
useEffect(() => {
if (!messaging) return;
const unsubscribe = messaging().onNotificationOpenedApp((remoteMessage) => {
console.log('通知タップで起動:', remoteMessage);
// → 特定の画面に遷移するなどの処理
});
return unsubscribe;
}, []);
// ③ 完全終了状態から通知タップで起動
useEffect(() => {
if (!messaging) return;
messaging().getInitialNotification().then((remoteMessage) => {
if (remoteMessage) {
console.log('kill状態から通知で起動:', remoteMessage);
}
});
}, []);
バックグラウンドハンドラーはコンポーネント外で登録
setBackgroundMessageHandler はアプリ起動前に呼ばれる可能性があるので、Reactコンポーネントの外(ファイルのトップレベル)で登録します。
// app/_layout.tsx のトップレベル(コンポーネント定義の外)
if (messaging) {
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
console.log('[FCM] Background message:', remoteMessage);
});
}
Step 8: Android固有の設定を入れる
iOSはapp.jsonだけでほぼ完結しますが、Androidはもうちょっと設定が必要です。
build.gradle の変更
// android/build.gradle
dependencies {
classpath('com.google.gms:google-services:4.4.0')
}
// android/app/build.gradle
apply plugin: 'com.google.gms.google-services'
通知アイコンの設定
Androidでは通知アイコンをカスタマイズできます。各解像度に合わせたアイコンを用意しましょう。
android/app/src/main/res/
├── drawable/
│ └── notification_icon.xml ← ベクター版
├── drawable-mdpi/
│ └── notification_icon.png ← 24x24
├── drawable-hdpi/
│ └── notification_icon.png ← 36x36
├── drawable-xhdpi/
│ └── notification_icon.png ← 48x48
├── drawable-xxhdpi/
│ └── notification_icon.png ← 72x72
└── drawable-xxxhdpi/
└── notification_icon.png ← 96x96
Tip: 通知アイコンは白抜きで
Androidの通知アイコンは白色のシルエットにする必要があります。 カラー画像をそのまま置くと、通知バーでグレーの四角になってしまうので注意してください。
完成したファイル構成
実装後のFCM関連ファイルはこうなりました。
src/
├── contexts/
│ └── FcmContext.tsx # FCMトークンのグローバル管理
├── hooks/
│ └── notification/
│ ├── index.ts # エクスポート集約
│ ├── useFcmToken.ts # トークン取得・パーミッション管理
│ └── useSendTestNotification.ts # テスト通知送信
├── services/
│ └── fcmService.ts # API呼び出し
├── types/
│ └── fcm.ts # 型定義
├── utils/
│ └── platformDetection.ts # Expo Go判定
└── app/
└── _layout.tsx # Provider配置 & 受信ハンドラー
ファイル数としては7つくらい。Hook / Context / Service / Type と役割で分けているので、 それぞれのファイルは短くて見通しが良い状態を保てています。
iOS と Android で違うところ
iOS
・GoogleService-Info.plist を ios/ に配置
・UIBackgroundModes に remote-notification 追加
・useFrameworks: "static" が必須
・requestPermission() で動的にパーミッション要求
・APNs証明書またはAPNsキーの設定が別途必要
Android
・google-services.json を android/app/ に配置
・POST_NOTIFICATIONS パーミッション宣言
・build.gradle にプラグイン追加
・通知アイコンの各解像度画像が必要
・Android 13以降はランタイムパーミッション必要
実装中にハマったところ
1. Expo Go でクラッシュする
最初、Expo Go で動かしたらアプリが即落ちしました。 Firebase Messaging がネイティブモジュールだということを知らなかった…というのが原因。 Step 4 で紹介した isFirebaseMessagingAvailable() を入れて、条件付き require() にすることで解決しました。
2. iOS で useFrameworks を忘れてビルドが通らない
app.json に "useFrameworks": "static" を入れ忘れると、pod install 時にFirebase関連のリンクがうまくいかずビルドエラーになります。 エラーメッセージがわかりにくいので、「Firebaseを入れたらとりあえず useFrameworks」と覚えておくと良いです。
3. フォアグラウンド通知が表示されない
バックグラウンドではOSが勝手に通知を表示してくれますが、フォアグラウンドでは自分でハンドリングしないと何も起きません。 onMessage で受け取って Alert.alert() で表示する処理を入れる必要がありました。 もっとリッチにやりたければ、notifee というライブラリを使うのもアリです。
まとめ
環境設定がちょっとめんどくさいのは事実ですが、コード自体はシンプルで、Custom Hook + Context というReactの基本パターンに収まります。
振り返ると、ステップは大きく分けてこれだけでした。
| ステップ | やること |
| 1-3 | ライブラリ導入 & 設定ファイル配置 |
| 4 | Expo Go対応の判定ロジック |
| 5-6 | FCMトークン取得 Hook & Context |
| 7 | 通知受信ハンドラー(3パターン) |
| 8 | Android固有設定 |
「プッシュ通知は大変そう」と後回しにしている方がいたら、まずは動くものを作ってみるところから始めてみてはいかがでしょうか。
次のステップとして
この記事ではシンプルに通知を受け取るところまでを紹介しましたが、プロダクションで使うならこのあたりも対応が必要です。
・FCMトークンのバックエンドへの自動送信・更新管理
・トピックベースの通知(特定グループへの配信)
・通知タップ時のディープリンク(特定画面への遷移)
・notifee を使ったリッチ通知の表示
使用技術: Expo SDK 50+ / React Native / @react-native-firebase/messaging / TypeScript
川口虎太郎/FIXER
本記事はアフィリエイトプログラムによる収益を得ている場合があります


この連載の記事
-
TECH
3週間の自動テストが半日に! Playwrightの使い方の基本 -
TECH
プロンプトエンジニアリングのコツは「5W1Hを忘れずに」 -
TECH
GitHubの 超・超・超 基本的な使い方まとめ -
TECH
業務で使えるExcel関数テクニック − 関数を使った動的な範囲指定のコツ -
TECH
zshの初期設定がダサい…。表示内容を自分好みにカスタマイズしていく -
TECH
Proxmox VE+OpenMediaVaultで自宅用NASを作ってみた -
TECH
Chrome拡張はVue.jsで作るのがおすすめ -
TECH
gitコマンド、長いしだるいしMMS(マジ・短く・したい) -
TECH
Terraformのバージョン管理ツール、古いtfenvからtenvへの移行 -
TECH
「SOSの出し方を知ろう」 新卒入社から1年、学んだことを振り返る -
TECH
機械科卒・ITエンジニア就職から一年、やって良かったこと - この連載の一覧へ




