💡 Tips

Expo ローカル通知をスケジュール実装する完全ガイド【Android/iOS対応】

この記事のゴール

expo-notifications を使って、指定日時・繰り返しのローカル通知をAndroid/iOS両方でスケジュール登録する実装を完成させます。

「通知が届かない」「権限ダイアログが出ない」「バックグラウンドで止まる」――個人開発でよくハマるポイントを先に潰してから実装に入るので、つまずきの少ない順序で読み進められます。


前提環境

項目バージョン
Expo SDK51以上
expo-notifications0.28系
React Native0.74系
ターゲットiOS 16+ / Android 13+ (API 33+)

Managed Workflow(expo コマンドで管理するプロジェクト)前提で説明します。Bare Workflowの場合も基本は同じですが、ネイティブの設定ファイルを直接編集する箇所が増えます。


1. インストールと app.json の設定

npx expo install expo-notifications

app.json(または app.config.js)に通知プラグインを追加します。ここを省略するとAndroidでサイレントに通知が届かないバグが頻発するので必須です。

{
  "expo": {
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#ffffff",
          "androidMode": "default",
          "androidCollapsedTitle": "#{unread_notifications} new interactions"
        }
      ]
    ],
    "android": {
      "permissions": [
        "android.permission.SCHEDULE_EXACT_ALARM",
        "android.permission.USE_EXACT_ALARM"
      ]
    }
  }
}

ポイント: Android 12(API 31)以降、正確なアラームには SCHEDULE_EXACT_ALARM または USE_EXACT_ALARM が必要です。後者はAPI 33以上で予約なしに使えますが、ストアポリシーを確認した上で採用してください。


2. 権限リクエスト:iOSはダイアログ必須

iOSはユーザーが明示的に許可しないと通知は届きません。アプリ起動時に一度だけ権限をリクエストするのが定石です。

import * as Notifications from 'expo-notifications';
import { useEffect } from 'react';
import { Platform, Alert } from 'react-native';

export async function requestNotificationPermission(): Promise<boolean> {
  // Android 13 未満は権限不要(自動許可)
  if (Platform.OS === 'android' && Platform.Version < 33) {
    return true;
  }

  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  if (existingStatus === 'granted') return true;

  const { status } = await Notifications.requestPermissionsAsync({
    ios: {
      allowAlert: true,
      allowBadge: true,
      allowSound: true,
    },
  });

  if (status !== 'granted') {
    Alert.alert(
      '通知が無効です',
      '設定アプリから通知を許可してください。',
    );
    return false;
  }
  return true;
}

よくあるハマりポイント①: Android 13+ の POST_NOTIFICATIONS

Android 13 以降、android.permission.POST_NOTIFICATIONS が必要です。expo-notifications 0.20+ では自動でマニフェストに追記されますが、expo-permissions を古いバージョンのまま使っているプロジェクトでは漏れることがあります。必ず Notifications.getPermissionsAsync() の戻り値を確認してください。


3. 通知ハンドラの設定

アプリがフォアグラウンドにいるときに通知を表示するには、ハンドラの設定が必要です。アプリのエントリポイント(App.tsx など)のモジュールトップレベルに記述します。

// App.tsx のトップレベル(コンポーネント外)
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

これを忘れると「バックグラウンドでは届くのにフォアグラウンドでは届かない」という現象になります。


4. ローカル通知のスケジュール登録

4-1. 指定日時に1回だけ送る

import * as Notifications from 'expo-notifications';

export async function scheduleOnceAt(date: Date, title: string, body: string): Promise<string> {
  const identifier = await Notifications.scheduleNotificationAsync({
    content: {
      title,
      body,
      sound: true,
      // Android: 通知チャンネルを指定(後述)
      ...(Platform.OS === 'android' && { channelId: 'default' }),
    },
    trigger: {
      date, // Dateオブジェクトをそのまま渡せる
      type: Notifications.SchedulableTriggerInputTypes.DATE,
    },
  });
  console.log('Scheduled notification id:', identifier);
  return identifier;
}

// 使用例: 30分後にリマインダー
const thirtyMinsLater = new Date(Date.now() + 30 * 60 * 1000);
await scheduleOnceAt(thirtyMinsLater, 'リマインダー', 'タスクを確認しましょう!');

4-2. 毎日同じ時刻に繰り返す

export async function scheduleDailyAt(hour: number, minute: number, title: string, body: string): Promise<string> {
  return Notifications.scheduleNotificationAsync({
    content: {
      title,
      body,
      sound: true,
    },
    trigger: {
      type: Notifications.SchedulableTriggerInputTypes.CALENDAR,
      hour,
      minute,
      repeats: true,
    },
  });
}

// 使用例: 毎朝8時30分
await scheduleDailyAt(8, 30, '朝のルーティン', '今日もがんばりましょう!');

4-3. スケジュール済み通知の一覧・キャンセル

// 一覧取得
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
console.log(scheduled);

// 単体キャンセル
await Notifications.cancelScheduledNotificationAsync(identifier);

// 全件キャンセル
await Notifications.cancelAllScheduledNotificationsAsync();

5. Androidの通知チャンネル設定

Android 8.0(API 26)以降、通知チャンネルがないと通知が表示されません。アプリ起動時に一度だけ作成します。

import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';

export async function setupNotificationChannel() {
  if (Platform.OS !== 'android') return;

  await Notifications.setNotificationChannelAsync('default', {
    name: 'デフォルト',
    importance: Notifications.AndroidImportance.MAX,
    vibrationPattern: [0, 250, 250, 250],
    lightColor: '#FF231F7C',
    sound: 'default',
  });
}

注意: Expo Go でテストするとチャンネル設定が正しく反映されないことがあります。通知周りは必ずカスタムdev build(eas build --profile development)か本番ビルドで確認してください。


6. バックグラウンド動作の注意点

iOSのバックグラウンド制限

iOSではローカル通知はOSが管理するためバックグラウンド制限の影響を受けません。スケジュール登録さえできれば、アプリが終了していても指定時刻に通知が届きます。一方でiOSはスケジュール登録できる通知の上限が64件に制限されています。繰り返し通知を大量に登録するアプリでは古いものを削除する管理ロジックが必要です。

Androidのバッテリー最適化

Android はメーカーやバージョンによってアプリをバックグラウンドで積極的にKILLします。特にXiaomi・OPPO・SamsungのカスタムROMでは「バッテリー最適化の除外」をユーザーに促す案内UIが効果的です。

import * as IntentLauncher from 'expo-intent-launcher';
import { Platform } from 'react-native';

export async function openBatteryOptimizationSettings() {
  if (Platform.OS !== 'android') return;
  await IntentLauncher.startActivityAsync(
    IntentLauncher.ActivityAction.IGNORE_BATTERY_OPTIMIZATION_SETTINGS,
  );
}

7. 通知タップ時の画面遷移

通知タップで特定画面に飛ばしたい場合は data フィールドとリスナーを使います。

import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { useNavigation } from '@react-navigation/native';

export function useNotificationNavigation() {
  const navigation = useNavigation();
  const responseListener = useRef<Notifications.Subscription>();

  useEffect(() => {
    responseListener.current = Notifications.addNotificationResponseReceivedListener(response => {
      const data = response.notification.request.content.data;
      if (data?.screen) {
        navigation.navigate(data.screen as never);
      }
    });

    return () => {
      responseListener.current?.remove();
    };
  }, [navigation]);
}

// 通知スケジュール時にdataを付与
await Notifications.scheduleNotificationAsync({
  content: {
    title: '習慣リマインダー',
    body: '今日のタスクを確認しましょう',
    data: { screen: 'TaskDetail' }, // タップ時に遷移する画面名
  },
  trigger: { date: triggerDate, type: Notifications.SchedulableTriggerInputTypes.DATE },
});

まとめ

チェック項目AndroidiOS
expo-notifications プラグイン設定✅ 必須✅ 必須
SCHEDULE_EXACT_ALARM 権限✅ API31+
通知チャンネル作成✅ API26+
ユーザー権限リクエスト✅ API33+✅ 必須
フォアグラウンド表示ハンドラ✅ 必須✅ 必須
スケジュール上限OSに依存(数百件)64件
Expo Goでのテスト⚠ 非推奨⚠ 非推奨

実装の流れを振り返ると:

  1. app.json にプラグインと権限を追加
  2. 起動時に通知チャンネル(Android)と権限リクエストを実行
  3. setNotificationHandler でフォアグラウンド表示を有効化
  4. scheduleNotificationAsync でトリガーを指定して登録
  5. 必ず実機またはカスタムdev buildで動作確認

個人開発のアプリに習慣トラッカーや学習リマインダーを組み込む際のベースとして、ぜひ活用してください。

📚 おすすめ書籍

React NativeとExpoによるモバイルアプリ開発

Expo/RN開発の全体像をしっかり学べる実践書

Amazonで見る →