Local Notifications
Schedule a notification on the device itself, create the Android channel it needs, and control what shows in the foreground.
await Notifications.scheduleNotificationAsync({ content: { title, body }, trigger })A local notification is one your app schedules on the device. No server and no network are involved. The system stores it and fires it at the time you asked, even if the app has been closed since. Reminders, timers and alarms are the usual reasons to reach for it.
Permission, channel, schedule
Ask with requestPermissionsAsync(). It resolves to an object carrying status and granted. Writing const { status } = await ... is shorthand for const result = await ...; const status = result.status; — the same thing.
On Android every notification belongs to a channel, created with setNotificationChannelAsync(id, { name, importance }). The channel owns the sound, the vibration and whether it may interrupt. iOS has no channels, so a channelId is ignored there.
Then call scheduleNotificationAsync({ content, trigger }). The trigger is an object whose type comes from SchedulableTriggerInputTypes: TIME_INTERVAL with seconds, DAILY with hour and minute, DATE with a date. It returns an identifier you can cancel later.
What trips people up
While the app is in the foreground nothing appears unless setNotificationHandler says it should. In Expo SDK 57 the handler returns shouldShowBanner and shouldShowList separately; the older single shouldShowAlert field is deprecated. The handler body async () => ({ ... }) is shorthand for async () => { return { ... }; }.
This is not the browser Notification API. There is no service worker, and no page-level prompt.
Example
import { useEffect, useState } from 'react';
import { Button, Platform, StyleSheet, Text, View } from 'react-native';
import * as Notifications from 'expo-notifications';
// Decides what happens when a notification fires while the app is open.
// Without this, nothing is shown in the foreground.
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
export default function ReminderScreen() {
const [scheduledId, setScheduledId] = useState<string | null>(null);
useEffect(() => {
// Android needs the channel to exist before anything is scheduled on it.
if (Platform.OS === 'android') {
Notifications.setNotificationChannelAsync('reminders', {
name: 'Reminders',
importance: Notifications.AndroidImportance.DEFAULT,
});
}
}, []);
async function schedule() {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return;
const id = await Notifications.scheduleNotificationAsync({
content: { title: 'Stand up', body: 'You have been sitting for ten seconds.' },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 10,
repeats: false,
channelId: 'reminders', // Android only, ignored on iOS
},
});
setScheduledId(id);
}
// Screen: a centred button with a small grey line of text under it. Tapping the
// button asks for permission once, then prints the schedule id; ten seconds later
// the notification appears as a banner even if the app is open.
return (
<View style={styles.screen}>
<Button title="Remind me in 10 seconds" onPress={schedule} />
<Text style={styles.id}>{scheduledId ?? 'nothing scheduled'}</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
id: { fontSize: 12, color: '#555555' },
});When to use it
- Remind a user to take a medication at a fixed time every day.
- Fire a notification when a cooking timer the user started finishes.
- Warn a user ten minutes before a calendar entry stored only on the device.
- Let a user silence your low-priority digest on Android without blocking urgent alerts.
More examples
One daily reminder, replacing the last one
A settings screen that lets the user pick a reminder time calls setDailyReminder with the chosen hour and minute. Because a DAILY trigger repeats indefinitely, scheduling a second one without cancelling leaves the user with two reminders; cancelAllScheduledNotificationsAsync avoids that. getAllScheduledNotificationsAsync is how you show the user what is actually pending rather than what you believe you scheduled.
import * as Notifications from 'expo-notifications';
export async function setDailyReminder(hour: number, minute: number): Promise<string> {
// A DAILY trigger repeats forever, so clear the old one before adding a new one.
await Notifications.cancelAllScheduledNotificationsAsync();
return Notifications.scheduleNotificationAsync({
content: {
title: 'Daily check-in',
body: 'Log today in thirty seconds.',
sound: 'default',
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour,
minute,
channelId: 'reminders',
},
});
}
export async function pendingIds(): Promise<string[]> {
const pending = await Notifications.getAllScheduledNotificationsAsync();
return pending.map((request) => request.identifier);
}
export async function cancel(id: string): Promise<void> {
await Notifications.cancelScheduledNotificationAsync(id);
}Building a trigger from a time string
Trigger objects are plain data, which makes them worth building in a small pure function you can unit test without a device. This is also where you would reject invalid input before it reaches scheduleNotificationAsync, which fails at runtime rather than at compile time when an hour is out of range.
import * as Notifications from 'expo-notifications';
// Turns "08:30" into a trigger object. No UI, no device needed.
export function dailyTrigger(
time: string,
channelId = 'reminders',
): Notifications.DailyTriggerInput {
// .map(Number) is shorthand for .map((part) => Number(part)) - the same thing.
const [hour, minute] = time.split(':').map(Number);
return {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour,
minute,
channelId,
};
}
const trigger = dailyTrigger('08:30');
console.log(trigger.hour, trigger.minute);
// Output: 8 30Two channels with different urgency
Call this once at startup. Separate channels exist so the user can silence your digest without silencing your alerts, in the system settings rather than in your app. Pick the channel per notification by passing its id as channelId on the trigger. Importance values run MIN, LOW, DEFAULT, HIGH, MAX; HIGH is what makes Android show a heads-up banner.
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
export async function createChannels(): Promise<void> {
if (Platform.OS !== 'android') return; // iOS has no channels at all
await Notifications.setNotificationChannelAsync('alerts', {
name: 'Time-sensitive alerts',
importance: Notifications.AndroidImportance.HIGH, // can pop over the screen
vibrationPattern: [0, 250, 250, 250],
sound: 'default',
});
await Notifications.setNotificationChannelAsync('digest', {
name: 'Daily digest',
importance: Notifications.AndroidImportance.LOW, // silent, no banner
sound: null,
});
}
Discussion