Expo + React Native で FCM プッシュ通知を簡単に実装してみる
2026-03-05
azblob://2026/03/02/eyecatch/2026-03-02-firebase-push-notification-000.png

はじめに

「モバイルアプリにプッシュ通知を入れたいけど、なんか難しそう…」と思っていませんか? 自分も最初はそう思ってました。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.png24x24
├── drawable-hdpi/
│   └── notification_icon.png36x36
├── drawable-xhdpi/
│   └── notification_icon.png48x48
├── drawable-xxhdpi/
│   └── notification_icon.png72x72
└── drawable-xxxhdpi/
    └── notification_icon.png96x96
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.plistios/ に配置
  • UIBackgroundModesremote-notification 追加
  • useFrameworks: "static" が必須
  • requestPermission() で動的にパーミッション要求
  • APNs証明書またはAPNsキーの設定が別途必要

Android

  • google-services.jsonandroid/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ライブラリ導入 & 設定ファイル配置
4Expo Go対応の判定ロジック
5-6FCMトークン取得 Hook & Context
7通知受信ハンドラー(3パターン)
8Android固有設定

「プッシュ通知は大変そう」と後回しにしている方がいたら、まずは動くものを作ってみるところから始めてみてはいかがでしょうか。

次のステップとして

この記事ではシンプルに通知を受け取るところまでを紹介しましたが、プロダクションで使うならこのあたりも対応が必要です。

  • FCMトークンのバックエンドへの自動送信・更新管理
  • トピックベースの通知(特定グループへの配信)
  • 通知タップ時のディープリンク(特定画面への遷移)
  • notifee を使ったリッチ通知の表示