RORK LABEN
TOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しましたTOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しました
記事一覧/開発ツール
開発ツール/2026-05-26中級

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

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

expo-notifications7iOS108push-notificationtroubleshooting25Rork515

「テスト通知をバックグラウンドで送ると出るのに、アプリを開いている状態だと無音で消える」——これは私自身、運用中の癒し系アプリで通知 A/B テストを始めた直後に再現させてしまった現象です。原因は単純で、iOS は仕様としてアプリ前面時の通知を OS 側で自動表示しないというところに行き当たります。

2014年からの個人開発で 累計 5,000 万ダウンロードを超えるアプリ群を運用してきましたが、expo-notifications 周りはバージョンを跨ぐと API が静かに変わっていくため、毎回新しい SDK に上げるタイミングで一度はこの罠を踏みます。落としどころが分かれば対応は数十行で済みますので、症状の見分け方から書いていきます。

症状と再現条件

確実に切り分けるために、次の3パターンを試します。

  • アプリをホーム画面に戻して通知を送る → バナーが出る
  • アプリを開いたまま通知を送る → 何も表示されない、音もしない
  • 通知センターを開く → 受信履歴には入っている

この組み合わせなら原因はほぼ「フォアグラウンドハンドラ未設定」または「iOS 14 以降の表示プロパティ未指定」です。サーバ側のペイロード問題やトークン取得失敗ではありません。受信履歴に入っていない場合だけ、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,
  }),
});

呼び出し位置は、ルートレイアウトの最上部や index.tsregisterRootComponent の直前など、最初のレンダー前に通る場所が安全です。useEffect の中に置くと、初回起動直後の通知に間に合わないことがあります。

原因 2: shouldShowAlert だけで書いている(iOS 14+ で非推奨)

ネット上に流通しているサンプルの多くは古い API を使っています。

// ❌ iOS 14 以降は警告が出て、新規 API への移行が推奨されています
handleNotification: async () => ({
  shouldShowAlert: true,
  shouldPlaySound: true,
  shouldSetBadge: true,
}),

iOS 14 で通知の表示先がバナー(画面上部)と通知センター(ロック画面・履歴)に分離されたため、shouldShowAlert は両方を同時にコントロールできなくなりました。expo-notifications v0.27 以降では shouldShowBannershouldShowList の併用が推奨されています。型エラーが出ない古いバージョンでも、内部的に旧 API がフォールバックされているだけなので、新しい書き方に揃えておくと安全です。

原因 3: 権限が provisional のまま

requestPermissionsAsync() を呼ばずに、getPermissionsAsync() の結果だけを信用しているケースです。iOS は初回インストール時に「サイレント通知のみ許可」の provisional 状態を返すことがあり、この状態ではフォアグラウンドのバナーが出ません。

import * as Notifications from "expo-notifications";
 
async function ensureNotificationPermission() {
  const current = await Notifications.getPermissionsAsync();
  // provisional でも granted 扱いになるので、明示要求が必要
  if (current.status === "granted" && current.ios?.status === Notifications.IosAuthorizationStatus.AUTHORIZED) {
    return true;
  }
 
  const requested = await Notifications.requestPermissionsAsync({
    ios: {
      allowAlert: true,
      allowSound: true,
      allowBadge: true,
      // フォアグラウンドのバナー・サウンドを明示的に許可
      allowDisplayInCarPlay: false,
      allowAnnouncements: false,
    },
  });
  return requested.status === "granted";
}

ios.status まで確認するのがポイントです。Android では granted で十分ですが、iOS は権限の粒度が細かいため、AUTHORIZEDEPHEMERAL を区別する必要があります。

原因 4: ペイロードに sound や title が欠けている

サーバから送るペイロードが薄いと、ハンドラ側で shouldShowBanner: true を返しても画面に出るものがありません。titlebody のどちらかが空文字だと iOS 側で破棄される挙動も確認しています。

Expo Push API で送る場合の最小例です。

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

_displayInForeground は Expo 側のフラグで、setNotificationHandler を併用している場合は冗長ですが、古い SDK との混在環境では入れておくと挙動が安定します。

原因 5: focus 通知やフォーカスモードに巻き込まれている

iOS 15 以降の集中モード(フォーカス)で「通知を許可するアプリ」から外されていると、フォアグラウンドでも通知が静かに飲まれます。自分の端末で「設定 → 集中モード → 仕事中 → 許可された App」を確認してください。複数の集中モードを切り替えて使っている方は、それぞれに登録漏れがないかをチェックします。

開発者として配布側で気をつけたい範囲ではありませんが、サポートに同じ問い合わせが届いたときに、ユーザーの設定を切り分ける質問として使えます。

カテゴリと通知アクションを設定すると挙動が変わることがある

通知に「返信」「あとで読む」などのアクションボタンを付けるために setNotificationCategoryAsync を呼んでいるアプリでは、カテゴリ未登録のペイロードを送ると iOS が判断に迷ってフォアグラウンドでの表示を遅延・破棄することがあります。再現が間欠的な場合は、サーバ側のペイロードに渡している categoryIdentifier がアプリ側で登録済みかを確認してください。

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

私のアプリでも、カテゴリを A/B テストで切り替えていた時期にこの現象を踏みました。サーバ側だけで挙動を変えると、古いビルドのユーザーにだけ通知が出ない、という観測しにくいバグになります。

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

ここまでを統合した最小コードです。ローカル通知を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" },
            trigger: { 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 を使うと、react-native log-ios でも同じログが流れます。

Android との挙動差をついでに確認する

Android では通知チャンネル(setNotificationChannelAsync)の importanceHIGH 未満だと、フォアグラウンドでヘッドアップ通知(画面上部のバナー)が出ません。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 以降の新 API(shouldShowBanner / shouldShowList)に統一する
  • 権限取得時は ios.status まで参照する
  • ローカル通知のテストボタンを設定画面に隠しておくと、ユーザー報告時の切り分けが速い

私の壁紙アプリでは、リリースビルドだけで再現する通知不具合に出会うと、まずこの再現コードを設定画面の奥に仕込んで TestFlight で配り、原因がアプリ側か OS 側かを最初の数時間で切り分けるようにしています。

ここまで読んでくださりありがとうございました。同じ症状で時間を溶かしている方の最短ルートになれば幸いです。

シェア

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

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

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

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

関連記事

開発ツール2026-06-22
毎朝違う一言を出す日替わりリマインダーが、ある日から鳴らなくなる——iOSの保留64件という見えない上限
Rork(Expo)で作った日替わりリマインダーが、しばらく経つと無言で止まる原因はiOSの保留通知64件上限です。固定文言のカレンダートリガーと、日替わりコンテンツのローリング再スケジュールを動くコードで設計し、サマータイムや複数リマインダー併用でも狂わない通知を組みます。
開発ツール2026-07-17
許可を聞かずに通知を届ける — Rork アプリの provisional 認可と、Expo の定番コードが握りつぶすもの
iOS には許可ダイアログを出さずに通知を配り始める provisional 認可があります。ところが Expo 公式が載せている定番の登録コードをそのまま使うと、provisional で通った端末が「拒否」として扱われ、避けたかったはずのダイアログが出ます。granted が false になる仕組みから、5値で許可状態を扱うフック、静かな配信を前提にした通知設計、昇格を求める判断、計測の分離までを実装込みで整理しました。
開発ツール2026-07-10
クラッシュとして記録されない強制終了 — Rork アプリの JetsamEvent を読む
Crashlytics には何も出ていないのにレビューで「勝手に閉じる」と書かれる。その多くはメモリ超過による OS からの強制終了です。JetsamEvent レポートの読み方と、画像中心アプリのメモリ上限を実測から設計する手順をまとめます。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →