◉RORK LABEN
●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Beta が 09/15 から。stable の案内はまだなく、Expo Go でのプロジェクト実行は 09/03 からログインが必要です●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Beta が 09/15 から。stable の案内はまだなく、Expo Go でのプロジェクト実行は 09/03 からログインが必要です●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…
記事一覧/開発ツール
⬡ 開発ツール/2026-05-26中級

iOSのフォアグラウンド通知が表示されない — expo-notifications のハマりどころと対処

iOS実機でアプリが前面にあるときだけプッシュ通知が表示されない問題を、expo-notifications の setNotificationHandler の正しい書き方と iOS 14 以降の表示プロパティの違いから整理します。

expo-notifications7iOS115push-notificationtroubleshooting26Rork584

「テスト通知をバックグラウンドで送ると出るのに、アプリを開いている状態だと音もなく消える」——運用中の癒し系アプリで通知の A/B テストを始めた直後、私はこの現象を自分の手で再現してしまいました。最初は送信側のペイロードを疑い、サーバのログを一晩ぶん読み返しましたが、芳しい手がかりはありませんでした。行き当たったのは、iOS はアプリが前面にあるとき、通知を OS 側で自動表示しない仕様だということです。

expo-notifications 周りは SDK を上げるたびに表示まわりの API が少しずつ入れ替わるため、新しい SDK に載せ替えるたびに、一度はこの罠を思い出すことになります。落としどころさえ分かれば対応は数十行で済みますので、症状の見分け方から順に書き残します。

症状と再現条件

原因を取り違えないために、次の 3 パターンを試します。

  • ホーム画面に戻して送信:バナーが表示される
  • アプリを開いたまま送信:表示も音もなし
  • 通知センターの確認:受信履歴には記録あり

この組み合わせなら、原因はほぼ「フォアグラウンドハンドラの未設定」か「表示プロパティの指定漏れ」です。サーバ側のペイロードやトークン取得の失敗ではありません。受信履歴にも入っていない場合に限って、APNs 証明書・FCM の Server Key・getExpoPushTokenAsync のスコープを疑ってください。

原因 1: setNotificationHandler を呼んでいない

iOS のフォアグラウンド通知は、アプリ側が「表示してください」と明示しない限り表示されません。expo-notifications では、この宣言を setNotificationHandler で行う設計になっています。

// app/_layout.tsx もしくはアプリ起動の最上流
import * as Notifications from "expo-notifications";
 
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

呼び出し位置は、ルートレイアウトの最上部や registerRootComponent の直前など、最初のレンダーより前に必ず通る場所が安全です。useEffect の中に置くと、コールドスタート直後に届いた通知に間に合わないことがあります。私も最初はコンポーネントの中に置いていて、起動直後の 1 通だけ消える、という再現しにくい形で悩まされました。

原因 2: shouldShowAlert だけで書いている

ネット上のサンプルの多くは、いまも古い API のままです。

// 旧 API:新しい SDK では非推奨。shouldShowBanner / shouldShowList へ移行する
handleNotification: async () => ({
  shouldShowAlert: true,
  shouldPlaySound: true,
  shouldSetBadge: true,
}),

iOS 14 で通知の表示先がバナー(画面上部)と通知センター(リスト)に分かれたため、shouldShowAlert の 1 つでは両方を別々に制御できなくなりました。expo-notifications 側でも、0.31(SDK 53)で shouldShowAlert が非推奨となり、shouldShowBanner と shouldShowList の組み合わせが案内されています。古い版のまま動いているように見えても、内部で旧 API に寄せられているだけの場合があります。SDK を上げる前に書き方を揃えておくほうが安全だと感じています。

原因 3: 権限が provisional のまま

requestPermissionsAsync() を呼ばず、getPermissionsAsync() の結果だけを信用しているケースです。iOS には、通知センターへ控えめに届けるだけの「仮の許可(provisional)」という状態があります。この状態は status 上では granted として返るため、見落としやすいところです。

import * as Notifications from "expo-notifications";
 
async function ensureNotificationPermission() {
  const current = await Notifications.getPermissionsAsync();
  // provisional も granted として返るので、ios.status まで見る
  if (
    current.status === "granted" &&
    current.ios?.status === Notifications.IosAuthorizationStatus.AUTHORIZED
  ) {
    return true;
  }
 
  const requested = await Notifications.requestPermissionsAsync({
    ios: {
      allowAlert: true,
      allowSound: true,
      allowBadge: true,
    },
  });
  return requested.status === "granted";
}

ios.status まで確認するのがポイントです。Android では granted だけで足りますが、iOS は権限の粒度が細かく、AUTHORIZED・PROVISIONAL・EPHEMERAL を区別する必要があります。

原因 4: ペイロードが薄くて描画するものがない

サーバから送るペイロードに title も body もなければ、ハンドラで shouldShowBanner: true を返しても、画面に出すものがありません。Expo Push API で送る場合の最小例です。

{
  "to": "ExponentPushToken[xxxxxxxx]",
  "title": "新しい壁紙が追加されました",
  "body": "今日のおすすめ画像を見る",
  "sound": "default",
  "badge": 1
}

古いサンプルでは _displayInForeground というフラグを付けた例も見かけますが、これは古い Expo クライアント向けの名残です。setNotificationHandler を使う現在の構成では、必要ないと考えています。

原因 5: 集中モード(フォーカス)に巻き込まれている

iOS 15 以降の集中モードで、アプリが「許可された App」に入っていないと、通知が表示されないことがあります。自分の端末で「設定 → 集中モード → 各モード → 許可された App」を確認してください。複数の集中モードを使い分けている方は、それぞれに登録漏れがないかも見ます。

開発者側で直せる範囲ではありませんが、サポートに同じ問い合わせが届いたとき、ユーザーの設定を切り分ける最初の質問として役に立ちます。

カテゴリと通知アクションの登録漏れ

通知に「保存」「開く」などのアクションボタンを付けるため setNotificationCategoryAsync を使っているアプリでは、サーバが送る categoryIdentifier がアプリ側で未登録だと、表示の挙動が不安定になることがあるかもしれません。再現が間欠的なときは、まずここを疑います。

await Notifications.setNotificationCategoryAsync("daily_pick", [
  { identifier: "save", buttonTitle: "保存", options: { opensAppToForeground: false } },
  { identifier: "open", buttonTitle: "開く", options: { opensAppToForeground: true } },
]);

私のアプリでも、カテゴリを A/B テストで切り替えていた時期にこの種の不具合に出会いました。サーバ側だけで挙動を変えたため、古いビルドのユーザーにだけ通知が出ない、という観測しにくい状態になっていたのです。サーバで増やしたカテゴリは、先にアプリ側で配ってから使います。 この順番だけは崩さないようにしています。

切り分け用の最小再現コード

ここまでを 1 つにまとめた最小のコードです。ローカル通知を 3 秒後に発火させるだけなので、サーバを介さずに挙動を確認できます。

import * as Notifications from "expo-notifications";
import { Button, View } from "react-native";
 
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});
 
export default function DebugScreen() {
  return (
    <View>
      <Button
        title="3秒後にローカル通知"
        onPress={async () => {
          const perm = await Notifications.requestPermissionsAsync();
          if (perm.status !== "granted") return;
          await Notifications.scheduleNotificationAsync({
            content: { title: "テスト", body: "前面表示の確認", sound: "default" },
            // 最近の版ではトリガーに type の指定が必要
            trigger: {
              type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
              seconds: 3,
            },
          });
        }}
      />
    </View>
  );
}

このコードで通知が出ないときは、ハンドラ未設定よりも手前の段階——権限の拒否や集中モード——を疑います。

ログで「ハンドラが呼ばれていない」を確かめる

setNotificationHandler は呼んでいるはずなのに直らない、というときは、ハンドラの中に console.log を置いて、Xcode のコンソールで観測します。

Notifications.setNotificationHandler({
  handleNotification: async (notification) => {
    console.log("[notify] foreground", notification.request.content.title);
    return {
      shouldShowBanner: true,
      shouldShowList: true,
      shouldPlaySound: true,
      shouldSetBadge: true,
    };
  },
});

[notify] foreground が流れていれば、ハンドラ自体は呼ばれています。原因は表示プロパティの組み合わせか、権限の側にあります。ログが一度も出ない場合は、ハンドラの登録位置が遅すぎるか、expo-notifications のインポート漏れを疑います。実機で確かめるときは、TestFlight ではなく Development Build を使うと、Metro 側のログでも同じ出力を追えます。

Android との挙動差もあわせて確認する

Android では、通知チャンネル(setNotificationChannelAsync)の importance が HIGH 未満だと、フォアグラウンドでヘッドアップ通知が出ません。iOS の問題を片付けたあとで「Android でも同じことが起きている」と気づく流れが多いので、運用するなら最低限のチャンネル設定も一緒に入れておきます。

import { Platform } from "react-native";
import * as Notifications from "expo-notifications";
 
if (Platform.OS === "android") {
  Notifications.setNotificationChannelAsync("default", {
    name: "default",
    importance: Notifications.AndroidImportance.HIGH,
    vibrationPattern: [0, 250, 250, 250],
    lightColor: "#FF231F7C",
    sound: "default",
  });
}

Android 13 以降は通知許可のランタイム要求も必要です。requestPermissionsAsync をプラットフォーム共通で呼ぶ流れにしておくと、新しい OS が出るたびにコードを書き換えずに済みます。

予防策

  • ルートレイアウトの最上部での setNotificationHandler 呼び出し
  • iOS 14 以降の表示キー(shouldShowBanner / shouldShowList)への統一
  • 権限取得時の ios.status まで含めた確認
  • 設定画面の奥に置くローカル通知のテストボタン(ユーザー報告の切り分けが速くなります)

リリースビルドだけで再現する通知の不具合に出会ったとき、私はまずこの再現コードを設定画面の奥に仕込んで TestFlight で配り、原因がアプリ側か OS 側かを最初の数時間で切り分けるようにしています。ペイロードを疑う前に端末側を潰しておくだけで、調べる範囲がずいぶん狭まるのだと気づいたのは、何度かこの罠を踏んだあとでした。

まずは手元の setNotificationHandler が shouldShowBanner と shouldShowList で書かれているかを確かめるところから始めていただければと思います。同じ症状で時間を溶かしている方の、最短ルートになれば幸いです。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⬡ 開発ツール2026-09-21
exit code 0 で止まるビルド — ログを黙らせていた設定と、空ファイルを通した存在チェック
eas build --local が exit code 0 but produced no further output で止まる件と、0 バイトのスタブが埋め込まれず dyld で落ちる件。どちらも原因は出力を減らす設定でした。自分のスクリプトを棚卸しする手順まで書き残します。
⬡ 開発ツール2026-09-05
Widget Extension でも BGTaskScheduler.submit は成功します。走らないのは register が親アプリだけのものだからです
Widget Extension から BGTaskScheduler に依頼を出すとビルドも submit も通るのに、翌朝ウィジェットは変わりません。register がホストアプリ専用である事実から、更新の主語を親アプリへ戻す設計まで、壁紙アプリの運用で詰めた手順を残します。
⬡ 開発ツール2026-07-27
最低サポート iOS をいつ上げるか — 利用率ではなく、コードに残った分岐の数で決める
最低サポート OS の引き上げを利用率で判断すると、毎年同じ結論になって先送りが続きます。OS 分岐に注記を付けて棚卸しし、iOS と Android の引き上げ先ごとに消せる分岐を数えるスクリプトと、引き上げ後に見る指標をまとめました。