How-to guides

Add Recurring Reminders to an Expo App

Use a repeating local notification for a reminder on this phone. Keep the notification identifier so editing or disabling the reminder cancels the previous request instead of creating duplicates.

Updated 28 October 2026

What you will build

Enable and Disable controls for a daily 9:00 reminder. The identifier survives an app restart, and enabling again replaces the previous request. This is a device-local reminder, not a server message to every device signed into an account.

  1. Step 1Ask when enabled
  2. Step 2Schedule one request
  3. Step 3Cancel when disabled

The example uses the SDK 58 delivery option bestEffort on purpose. Ordinary reminders may be delayed by system settings and power management. Exact Android alarms are a separate permission and policy decision; a scheduled request is not a delivery guarantee.

Build it in Newly

Open your project in Newly and describe the behavior you want. Use this suggested prompt as a starting point, then review the generated changes and follow the checks below.

Suggested prompt
Add a daily reminder setting using expo-notifications. Ask for notification permission when the user enables it, create an Android notification channel, and schedule one repeating daily request. Persist the returned identifier, cancel the previous request when the time changes, and let the user disable it after reopening the app. Use best-effort delivery for this ordinary reminder; do not silently request exact-alarm access or promise delivery to the second. Show a clear denied-permission state and keep the setting consistent with pending notification requests.

  1. 1. Configure the native app and ask at the right moment

    Install the package and add expo-notifications to the app config plugins. Rebuild the native app when its configuration changes. Put foreground notification handling in one app-level location. On Android, create the channel before asking for permission; ask when the person enables a reminder, rather than during unrelated onboarding.

  2. 2. Store and replace the scheduled identifier

    Use a daily trigger for a wall-clock time. Save the identifier returned by scheduleNotificationAsync in persistent app storage. When the person changes the setting, cancel the old request and schedule its replacement. Prevent overlapping button actions so repeated taps cannot create multiple reminders.

  3. 3. Make disable and delivery expectations explicit

    Disable should cancel the saved identifier and clear it from storage. Inspect getAllScheduledNotificationsAsync when diagnosing mismatches. Test notification behavior while the app is foregrounded, backgrounded and closed. For account-wide reminders or changes made by another person, design a server synchronization flow separately.

Expo implementation example

Install the dependencies in your Expo project. Expo resolves native package versions for your installed SDK.

Install command
npx expo install expo-notifications @react-native-async-storage/async-storage

Code example
import { useRef, useState } from "react";
import { Button, Platform, Text, View } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications";

const KEY = "daily-reminder-id";
const CHANNEL = "daily-reminders";

// Merge this with your app-level handler if one already exists.
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldPlaySound: true, shouldSetBadge: false,
    shouldShowBanner: true, shouldShowList: true,
  }),
});

function mayNotify(permission: Notifications.NotificationPermissionsStatus) {
  return permission.granted ||
    permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL ||
    permission.ios?.status === Notifications.IosAuthorizationStatus.EPHEMERAL;
}

export default function DailyReminder() {
  const [busy, setBusy] = useState(false);
  const [status, setStatus] = useState("");
  const running = useRef(false);

  async function update(enabled: boolean) {
    if (running.current) return;
    running.current = true;
    setBusy(true);
    try {
      if (enabled) {
        if (Platform.OS === "android") {
          await Notifications.setNotificationChannelAsync(CHANNEL, {
            name: "Daily reminders",
            importance: Notifications.AndroidImportance.DEFAULT,
          });
        }
        let permission = await Notifications.getPermissionsAsync();
        if (!mayNotify(permission)) permission = await Notifications.requestPermissionsAsync();
        if (!mayNotify(permission)) {
          setStatus("Notifications are not allowed. Review this app in system settings.");
          return;
        }
      }
      const previous = await AsyncStorage.getItem(KEY);
      if (previous) await Notifications.cancelScheduledNotificationAsync(previous);
      await AsyncStorage.removeItem(KEY);
      if (enabled) {
        const id = await Notifications.scheduleNotificationAsync({
          content: { title: "Daily check-in", body: "Take a moment to check in.", sound: "default" },
          trigger: {
            type: Notifications.SchedulableTriggerInputTypes.DAILY,
            hour: 9, minute: 0, channelId: CHANNEL, delivery: "bestEffort",
          },
        });
        try {
          await AsyncStorage.setItem(KEY, id);
        } catch (error) {
          await Notifications.cancelScheduledNotificationAsync(id);
          throw error;
        }
      }
      setStatus(enabled ? "Daily reminder scheduled. Delivery follows system settings." : "Reminder disabled.");
    } catch {
      setStatus("Could not update the reminder. Please try again.");
    } finally {
      running.current = false;
      setBusy(false);
    }
  }

  return (
    <View style={{ gap: 16, padding: 24 }}>
      <Button title="Enable daily reminder" disabled={busy} onPress={() => update(true)} />
      <Button title="Disable reminder" disabled={busy} onPress={() => update(false)} />
      <Text accessibilityLiveRegion="polite">{status}</Text>
    </View>
  );
}

Add the expo-notifications config plugin and use a native build when testing your own app configuration. This example requests best-effort delivery using the SDK 58 API; check compatibility before copying it into an older project. It intentionally does not request exact-alarm permission.

Test before publishing

  • On a fresh install, enable the reminder and test both accepting and denying notification permission.
  • Enable twice and inspect pending requests; only one request for this reminder remains.
  • Restart the app, disable the reminder and confirm that its pending request is gone.
  • For a quick test, schedule a separate one-off notification a minute ahead, verify delivery and then cancel or remove that test request.
  • Test foreground, background, notification settings and a time-zone change on physical devices; do not promise exact delivery from a simulator result.

Run these checks in your own project on iOS and Android. A browser preview does not establish native permission, hardware or background behavior.

Common problems

The reminder duplicates after editing

Persist its identifier and cancel that request before replacing it. Keep enable, edit and disable actions serialized; a disabled button alone can still leave overlapping asynchronous work if other code updates the same setting.

The reminder does not alert at the expected minute

Check system permission, the Android channel, Focus or notification settings, pending requests and the chosen delivery mode. Exact alarms on recent Android versions require separate access and must fit the product and store policies.

Questions about recurring reminders

Do recurring local reminders need a server?

A reminder scheduled on this device does not need your server to send each occurrence. Cross-device settings, messages triggered by another person and shared account state require a separate synchronization or server-push design.

Should I schedule a year of individual notifications?

Prefer one repeating request when its recurrence matches the product. Keep the identifier for editing and cancellation. Inspect pending requests and test your target operating systems rather than treating a limit quoted from a deprecated API as a universal modern guarantee.

Does a daily reminder require exact-alarm permission on Android?

SDK 58 distinguishes best-effort delivery from exact delivery. The example chooses bestEffort. Exact delivery on Android 12 and later involves separate alarm access, and recent Android versions may deny it by default. Request it only when the feature and applicable policies justify it.

What does provisional permission mean on iOS?

Provisional authorization can allow noninterruptive notifications. It does not mean the app can guarantee a sound or banner. Respect the user’s current settings and describe the reminder as scheduled rather than promising a loud alert.

Sources and API references

Keep building

Build this feature in Newly