アプリが動作し、ユーザーがログインできるようになった段階で、プロダクト側からはネイティブな体験を生む再エンゲージメント施策が求められるようになります。カートのリマインダー、レビュー依頼、新着メッセージ通知、リリースのお知らせなどです。最初は「とりあえずプッシュ通知を繋げばいい」と考えがちですが、1週間後には「なぜ特定のデバイスにだけ通知が届くのか」「シミュレーターでは正常に登録されているのに」「タップしても正しい画面が開かないのはなぜか」といったデバッグに追われることになります。
Expoのプッシュ通知は、驚くほどシンプルに使えることもあれば、予想外に脆い側面もあります。
ExpoはReact Nativeチームに対し、APNsやFCMの上に実用的なレイヤーを提供しており、それこそが多くのチームに選ばれている理由です。しかし、デモレベルと本番環境レベルの実装には大きな隔たりがあります。トークンのライフサイクル、権限取得のタイミング、リスナーの設定、ペイロードの設計、そしてバックエンドのクリーンアップ、これらすべてが重要です。頻繁にアプリロジックの変更をリリースする場合、特にリテンション施策が確実なメッセージ配信とリリース速度に依存しているなら、運用面での規律がより一層求められます。配信は、その周りのユーザー体験が予測可能でなければ意味がありません。
目次
- Expoプッシュ通知によるユーザーエンゲージメントの基盤
- プロジェクトの初期セットアップと設定
- 権限のリクエストとPushトークンの取得
- サーバーからの通知送信
- アプリでの通知受信のハンドリング
- 本番環境のベストプラクティスとよくある落とし穴
Expoプッシュ通知によるユーザーエンゲージメントの基盤
OTA Expoプッシュ通知 のセットアップが何より魅力的なのは、チームが初日から抱えたくないネイティブメッセージングの複雑さを大幅に取り除けるからです。APNsやFCMの接続部分を最初から自分で構築する代わりに、Expoのゲートウェイを使えば、プロダクトの動作、ルーティング、権限UX、バックエンドのメッセージロジックに集中できます。
この抽象化はプッシュを「よりリアルでなくする」わけではありません。エンジニアリングの工数をどこに割くかが変わるだけです。
また、このサービスは十分に高速なので、通常パフォーマンスが最初の心配事になることはありません。2023年3月14日から2023年6月12日まで、Expoのプッシュ通知 API は次を示しました: 応答時間の中央値42ミリ秒, p99レイテンシ273ミリ秒、および 1日平均エラー率0.17% を 1日あたり数千万件のメッセージ。出典は KnockによるExpoプッシュ API ベンチマーク分析これにより、Expoがプロトタイプ制作にしか向かないのではないかと懸念しているチームも、安心して利用できるはずです。
Expoが実際に抽象化しているもの
「Expo push」と言うとき、多くのチームは実際には以下のようないくつかの異なる要素をまとめて指しています:
- プロバイダーのルーティング: Expoはメッセージを以下へ転送します: APNs (iOS用)および FCM (Android用)。
- トークンの形式: プラットフォーム固有のトークン管理を最初に行う代わりに、サーバー側でExpo Push Tokenを保存・送信します。
- リクエストの仕様: ネイティブプロバイダーのAPIを直接統合するのではなく、Expoのプッシュ API にペイロードをPOSTします。
これは便利ですが、よくある誤解も生みます。チームは、配信に関するすべての問題がExpoの責任だと考えてしまいがちです。実際には、アプリの code、期限切れのトークン、不正なペイロード、または不適切な権限フローが原因で失敗することが多々あります。
実践ルール: Expoを、適切なクライアントおよびバックエンド設計の代わりとしてではなく、信頼できるトランスポート層として扱ってください。
本番対応が本当に意味するもの
動作するデモは、あるデバイスが一度だけペイロードを受け取ったことを証明するに過ぎません。プロダクション環境への対応とは、それとは別の意味を持ちます:
| 懸念事項 | デモ重視の考え方 | プロダクション重視の考え方 |
|---|---|---|
| 権限 | すぐに質問する | 価値が明確になってから、文脈に合わせて質問する |
| トークン | 一度保存するだけ | 更新、重複排除、期限切れ、同期 |
| ペイロード | あらゆるデータを投入 data |
ペイロードは軽量かつアクション指向に |
| アプリの挙動 | アラートを表示 | 正しい遷移とフォアグラウンド状態の制御 |
| 運用 | 手動テスト | レシート、クリーンアップ、ログ、障害対応 |
「通知を送信できること」と「通知が実際のプロダクトワークフローを支えられること」には、大きな違いがあります。
プロジェクトの初期セットアップと設定
Expoのプッシュ通知におけるトラブルの多くは、最初の権限プロンプトが出る前に発生しています。プロジェクトの設定が不十分だと、クライアント側の code は正しく見えても、ビルドごとにアプリの挙動が不安定になることがあります。

まずは適切なライブラリをインストールし、ビルドパスに合わせた開発環境を整えましょう。Expo Goの枠を超えて開発する場合、リリースワークフローを以下と合わせておくと役立ちます。 Capgo による Expo EAS 代替ガイドなぜなら、通知の挙動は、単なるサンドボックス実行よりも、本番環境に近いビルドで検証する必要があるからです。
通知パッケージのインストール
最低限、通常は以下が必要になります:
expo-notifications権限リクエスト、トークン取得、リスナー、および通知の表示用です。expo-deviceトークン取得は、以下を使用して保護すべきだからです:Device.isDevice.
インストールコマンドは使用しているパッケージマネージャーによりますが、重要なのは Expo SDK とのバージョンの一致です。任意のパッケージバージョンを混ぜないようにしましょう。互換性のあるバージョンは Expo に解決させてください。
プロジェクトレベルの設定を追加する
設定は明示的に保ちましょう。最小限の app.json または app.config.js 設定は、通知が後付けではなく、アプリの仕様の一部であることを反映すべきです。
{
"expo": {
"name": "MyApp",
"slug": "my-app",
"plugins": ["expo-notifications"],
"ios": {
"bundleIdentifier": "com.example.myapp"
},
"android": {
"package": "com.example.myapp"
},
"extra": {
"eas": {
"projectId": "your-project-id"
}
}
}
}
ここで重要な詳細がいくつかあります:
- Bundle identifier と package name は 実際にリリースするアプリと一致させる必要があります。
- 通知プラグインは、 ビルド時にネイティブプロジェクトに必要なセットアップを確実に実行します。
- EAS project ID は、 トークン取得時にアプリが正しい Expo プロジェクトに関連付けられている必要がある場合に重要です。
通知ハンドラーを早めに設定する
多くのチュートリアルでは、通知の挙動の定義が後回しになりがちです。そうすべきではありません。フォアグラウンドでの挙動を予測可能にするため、アプリの起動時に定義しましょう。
import * as Notifications from 'expo-notifications';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: false,
shouldSetBadge: true,
}),
});
フォアグラウンド通知でアラートを表示するか、音を鳴らすか、バッジを更新するかは、チームで決定すべき事項です。具体的な挙動はプロダクトに依存します。チャットアプリと決済アプリでは、選択が異なるはずです。
フォアグラウンドの挙動を意図的に定義していないと、実際には受信しているのにプロダクトの想定通りに表示されない「通知の不達」というバグの調査に追われることになります。
Androidにはチャンネル設定が必要です
実務において、Androidの通知チャンネルは必須です。これをスキップすると、アラートの表示が不整合になったり、ユーザーの期待に沿わなくなったりする可能性があります。
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
export async function configureAndroidNotifications() {
if (Platform.OS !== 'android') return;
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.MAX,
});
}
これはアプリの初期化時に設定してください。その後、チャンネルIDは固定しましょう。安易に変更してしまうと、後から通知の挙動を把握するのが困難になります。
権限のリクエストとPushトークンの取得
これは多くのチームがスニペットをそのままコピーしてしまい、後で後悔する部分です。
権限リクエストには、適切なタイミング、プラットフォームへの配慮、そして規律ある非同期処理が必要です。トークンの取得は、実機のみ、権限確定後のみ、かつ取得結果を即座にバックエンドに保存できる準備が整っている場合にのみ行うべきです。

ベースラインとして活用すべきクライアント関数
まずは以下のような関数を起点として実装してください:
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
type RegisterResult =
| { ok: true; token: string }
| { ok: false; reason: string };
export async function registerForExpoPushNotificationsAsync(): Promise<RegisterResult> {
if (!Device.isDevice) {
return { ok: false, reason: 'Push notifications require a physical device.' };
}
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.MAX,
});
}
const permissions = await Notifications.getPermissionsAsync();
let finalStatus = permissions.status;
if (finalStatus !== 'granted') {
const request = await Notifications.requestPermissionsAsync();
finalStatus = request.status;
}
if (finalStatus !== 'granted') {
return { ok: false, reason: 'Notification permission was not granted.' };
}
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
return { ok: false, reason: 'Missing EAS project ID configuration.' };
}
const tokenResponse = await Notifications.getExpoPushTokenAsync({ projectId });
return { ok: true, token: tokenResponse.data };
}
実行順序が重要です。まずデバイスタイプを確認し、Androidのチャンネル設定を行い、権限を解決し、プロジェクト構成を検証した上で、最後にExpoトークンをリクエストします。
理由 Device.isDevice は必須です
これは、一見無害に見えながらも、多くのノイズを生む数少ないミスの一つです。熟練したチームは、以下の場合にのみ条件付きで権限をリクエストします。 Device.isDevice が true の場合よくある落とし穴は、このガードをスキップしてしまうことです。その結果、無効なシミュレーターのトークンに通知を送信してしまい、実際にはアプリの設定に問題があるにもかかわらず、Expoのせいにすることになります。詳細は以下を参照してください。 EagerworksによるExpo通知実装ノート.
だからこそ、チェック処理を関数の先頭に配置するのです。ヘルパー関数の中に隠さず、明示的に記述してください。
シミュレーターの結果はUIテストには有用ですが、プッシュトークンの登録検証には信頼できません。
適切なタイミングで権限をリクエストする
スプラッシュ画面でリクエストしてはいけません。ユーザーが価値を理解する前にリクエストするのもNGです。最適なタイミングは、配送状況の有効化、会話への参加、ウォッチリストへの保存など、通知のメリットが具体化するユーザーアクションの後です。
優れた実装は、通常以下のフローに従います。
- ユーザーが重要な機能の境界に到達します。
- アプリ独自のUIで、通知の価値を説明します。
- アプリがシステム権限をリクエストします。
- 許可が得られた場合、アプリは即座にトークンをバックエンドに保存します。
多くのアプリが失敗するのは、この最後のステップです。トークンを取得してローカルにログを残すだけで、バックエンドへの登録を後回しにしてしまいます。これでは、後からサポート担当者が「どのデバイスが、いつ、どのトークンを持っていたか」を特定できなくなります。
登録後にトークンを保存する簡単な例は以下の通りです:
export async function enablePushForCurrentUser(userId: string) {
const result = await registerForExpoPushNotificationsAsync();
if (!result.ok) {
return result;
}
await fetch('https://api.example.com/push-tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer user-session-token',
},
body: JSON.stringify({
userId,
token: result.token,
platform: Platform.OS,
}),
});
return result;
}
ワークフローの後半では、この手順が視覚的なリファレンスとして役立ちます:
頻繁にリリースを行うチームにとって、トークンの登録は単なるオンボーディングの一部ではなく、アプリの運用状態(operational state)の一部として考えるのが有効です。この考え方は、より広範な Expoアプリのデリバリーワークフローにおいて、アプリの挙動が頻繁に変化し、バックエンドの状態を同期し続ける必要がある場合に非常に適しています。
サーバーからの通知送信
バックエンドに有効なExpo Push Tokenがあれば、通知の送信は簡単です。難しいのはリクエストそのものではなく、ペイロードに何を含めるか、そしてクライアントの状態をどこまで信頼するかという判断です。
以下は、Nodeスタイルの最小限の例です。 fetch:
type ExpoPushMessage = {
to: string;
title: string;
body: string;
sound?: 'default' | null;
data?: Record<string, unknown>;
};
export async function sendExpoPushNotification(token: string) {
const message: ExpoPushMessage = {
to: token,
title: 'New review received',
body: 'Tap to open the order details.',
sound: 'default',
data: {
type: 'new_review',
orderId: 'ord_123',
screen: 'OrderDetails',
},
};
const response = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: {
Accept: 'application/json',
'Accept-encoding': 'gzip, deflate',
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
});
const result = await response.json();
return result;
}
各ペイロードフィールドの役割
ペイロードを単なるデータの詰め込み場所にしないでください。各フィールドに明確な意図を持たせましょう。
| 項目 | 目的 | 実践的なアドバイス |
|---|---|---|
to |
Expo Push Tokenをターゲットにする | 現在のデバイスレコードに属しているか検証する |
title |
通知のヘッダー | 短く、人間が読みやすい内容にする |
body |
メインの表示テキスト | アクションを明確にする |
sound |
システム音の挙動 | 重要なアラートにのみ限定して使用 |
data |
アプリ固有のメタデータ | リッチなコンテンツよりIDやルートのヒントを優先 |
この data このオブジェクトを活用することで、プロダクトのワークフローがより実用的になります。タイプとレコードIDを渡しておけば、ユーザーがタップした際にアプリ側で最新データをフェッチさせることができます。ペイロードに巨大なデータや機密性の高いデータを直接埋め込むよりも、この方が安全です。
ペイロードは軽量かつシンプルに保つ
〜によると CourierのExpo通知ガイド、Expo Push Tokensは一時的なものとして扱うべきです。サイズ制限の約 4 KB を超えるペイロードは破棄される可能性があるため、以下のような小さなメタデータのみを送信するのが確実なパターンです。 { "type": "new_review", "id": 123 } 巨大なJSONやインラインメディアを避ける。このアドバイスは実際のシステムでも有効です。ペイロードを小さく保つことで、エラー率を下げ、アプリのロジック変更にも柔軟に対応できます。
ユーザーをルーティングするのに十分なデータのみを送信し、残りのデータはアプリ起動後に取得するようにします。
推奨されるサーバー側の習慣
テスト用であればシンプルな送信関数で十分ですが、本番環境では通常、以下のような責務を追加します:
- 送信試行の永続化: 通知の意図(ユーザーID、トークン、ペイロードタイプ、タイムスタンプ)を保存します。
- コンテンツ生成と転送の分離: メッセージの作成レイヤーと、Expo API リクエストを行うレイヤーを分けます。
- 無効化フィードバックの処理: 後からExpoから通知があった場合、
DeviceNotRegisteredそのトークンを期限切れとしてマークし、盲目的なリトライを停止します。 - Webhookに適した設計: システムがすでにイベントを発行しているなら、通知トリガーは、他で使っているのと同じ種類の バックエンドwebhook処理パターン にルーティングしてください。
クライアント側のリスナーをデバッグする前に、まず手動でテストプッシュを送信しましょう。小さなペイロードのシンプルな通知がトークンに届くなら、配信経路はおそらく正常です。届かない場合でも、まずナビゲーションの code を変更しないでください。まずトークン、ペイロードの形状、権限状態を検証しましょう。
アプリ内での受信通知のハンドリング
配信は機能の半分にすぎません。通知が届いたときと、ユーザーがタップしたときに、アプリが一貫した動作をする必要があります。
つまり、2つの別々のタイミングを処理する必要があります:
- アプリを開いているときに通知が届いた場合
- ユーザーがシステムトレイやロック画面から通知を操作したとき

フォアグラウンドでの受信とユーザーの応答は別のイベントです
信頼できる構成には、通常両方のリスナーを含めます:
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
export function useNotificationObservers(
onForegroundMessage: (notification: Notifications.Notification) => void,
onNotificationTap: (response: Notifications.NotificationResponse) => void
) {
useEffect(() => {
const receivedSub = Notifications.addNotificationReceivedListener(
(notification) => {
onForegroundMessage(notification);
}
);
const responseSub = Notifications.addNotificationResponseReceivedListener(
(response) => {
onNotificationTap(response);
}
);
return () => {
receivedSub.remove();
responseSub.remove();
};
}, [onForegroundMessage, onNotificationTap]);
}
addNotificationReceivedListener アプリがアクティブなときに実行されます。 addNotificationResponseReceivedListener ユーザーが配信された通知をタップしたときに実行されます。両者を頭の中で混同しないでください。UX の導線は異なります。
データペイロードを読み取り、意図した画面へ遷移させる
タップ処理の実践的なパターンは次のとおりです:
type NotificationData = {
type?: string;
orderId?: string;
screen?: string;
};
export function handleNotificationTap(
response: Notifications.NotificationResponse,
navigation: any
) {
const data =
response.notification.request.content.data as NotificationData;
if (data.screen === 'OrderDetails' && data.orderId) {
navigation.navigate('OrderDetails', { orderId: data.orderId });
return;
}
if (data.type === 'new_review') {
navigation.navigate('Inbox');
return;
}
navigation.navigate('Home');
}
このパターンは、ペイロードにドキュメント全体ではなくルーティングのヒントが含まれるため、堅牢性を保てます。通知の送信後に注文内容が変わっていても、遷移後にアプリが最新のサーバー状態を取得できます。
通知をタップしたら、明確に1つの遷移先へ導くべきです。フォールバックの遷移先が曖昧だと、ユーザーはすぐに気づきます。
フォアグラウンドの挙動はユーザーのコンテキストに合わせるべきです
アプリがすでに開いているときに、システム風のアラートを何も考えずに表示すると不自然に感じられます。アプリ内バナー、バッジの更新、サイレントリフレッシュが適切な場合もあります。ユーザーがすでにその会話を読んでいるなら、サポート受信トレイ画面に目立つアラートは不要かもしれません。
そのため、フォアグラウンドのリスナーはルートと通知タイプで分岐させるべきです。例えば:
- チャット画面が開いている場合: メッセージを追加し、重複するバナーを避ける
- ダッシュボードが開いている場合: 軽量なインアプリトーストを表示する
- アカウントの重大なイベント: より強調されたUI表示を行う
シンプルな実装例は以下の通りです:
export function handleForegroundNotification(
notification: Notifications.Notification,
currentRouteName: string
) {
const data = notification.request.content.data as { type?: string };
if (currentRouteName === 'ChatThread' && data.type === 'new_message') {
// refresh local thread state
return;
}
// otherwise show your own in-app UI or update badges
}
アプリ内でこれらのコンテキストを区別していないと、たとえ通知の配信自体が正しくても、ユーザーはすぐに通知疲れを感じてしまいます。
本番環境でのベストプラクティスとよくある落とし穴
Expoのプッシュ通知設定がうまくいかない原因の多くは、Expoの機能不足ではありません。トークンは永続的であり、ペイロードには何でも含められ、アプリのアップデートが通知ロジックに影響しないと思い込んでいることが原因です。
その想定は、本番環境では通用しません。

トークンは一時的なものであり、識別子ではない
Expo Push Tokenは、生涯使い続けるデバイス識別子ではなく、あくまで「リース(貸与)」のように扱うのが最適です。トークンは再インストールやOSの変更、その他のライフサイクルイベントによって更新される可能性があります。もしトークンが再び返ってきたとしても、 DeviceNotRegisteredバックエンド側では、それをアクティブなものとして扱い続けないようにすべきです。
実用的なバックエンドモデルでは以下を保存します:
- ユーザーID
- プラットフォーム
- インストール単位のメタデータ
- 現在のトークン
- 最終確認日時
- active、stale、revokedなどのステータス
ユーザーテーブルにトークンフィールドを1つ作るだけで済ませてはいけません。ユーザーは複数のデバイスを持ち、デバイスの状態は変化するためです。
リフレッシュ戦略は、多くのチュートリアルが示唆する以上に重要です
公式のエコシステムガイドでは、運用上の重要なギャップが残されています。既存のExpoプッシュ通知に関するコンテンツの多くは、以下にわたるトークンの有効性維持について説明していません: App Storeの審査サイクルやOTAアップデートこれは、頻繁にリリースを行うチームにとって特に重要です。なぜなら、プッシュ通知の信頼性は、後述するように現在のトークン状態とバックエンドの同期に依存するからです。 Expo 通知ドキュメント.
これはリフレッシュのトリガー設計に影響します。トークン状態を同期すべきタイミングは以下の通りです:
- アップデート後のアプリ起動時
- ユーザーのサインイン時
- 権限設定の変更時
- リリースプロセスにおける認証情報の更新時
- プッシュ通知関連のサポート対応後のリカバリフロー
セキュリティとコンプライアンスはスプリントの最後に回すべきではない
多くのExpoチュートリアルは仕組みの解説に終始し、運用リスクを軽視しています。趣味の開発なら問題ありませんが、ヘルスケア、フィンテック、規制対象のコマース製品では許容されません。
CourierによるExpo通知の欠落に関するエンタープライズ向け考察では、 同意ログの記録、監査トレイル、機密性の高いペイロード露出の最小化に関する実践的なガイダンスが不足していることが指摘されています。エンジニアが取るべき対策はシンプルです:
- 通知のテキストやペイロードのメタデータに、機密性の高いビジネスデータを入れないこと。
- 同意内容の変更をサーバー側でログに記録する。
- どの通知意図がどのトークンに送信されたかを記録する。
- ペイロードにIDを含め、アプリ起動後に保護されたコンテンツを取得する。
リリース運用を以下の項目と整合させるチームの場合、 App Storeのコンプライアンスや API のセキュリティ慣行、プッシュ通知も認証、分析、バックエンドのイベントログと同様のレビュー対象にすべきです。
プッシュ通知はユーザー向けのメッセージですが、分散システムの課題でもあります。認証状態や決済イベントと同様の注意を払って扱いましょう。
うまくいくパターンと、失敗するパターン
| うまくいくパターン | 失敗するパターン |
|---|---|
| 価値を明確に説明した後に権限をリクエストする | 最初の画面表示時にプロンプトを出す |
| 実機でのテスト | シミュレーターの登録を信頼する |
| デバイスコンテキストと共にトークンを保存する | ユーザーレコードごとに1つのトークン |
| 小さなメタデータペイロードを送信する | 巨大または機密性の高いBlobを埋め込む |
| フォアグラウンドとタップイベントを個別に処理する | すべての通知が単一のパスを通ると想定する |
| 古いトークンを積極的に期限切れにする | 無効なトークンへのリトライを永遠に繰り返す |
堅牢なExpoプッシュ通知の設定は複雑ではありません。規律が重要です。
頻繁にアプリロジックを変更し、リリース挙動、ロールバック、配信状況の可視化をより厳密に制御したいチームなら、 Capgo チェックする価値があります。ストアの審査を待たずにモバイルチームが迅速にアップデートを配信できるため、通知フロー、ルーティングロジック、クライアント側の修正などをユーザーに素早く届けたい場合に非常に有用です。