Module 8: Native Features and Device APIs
Notifications
Push notifications, local notifications, and handling user interactions
π― Learning Objectives
- Understand the difference between push and local notifications
- Request notification permissions properly
- Get and use Expo push tokens
- Schedule local notifications
- Handle notifications when app is in foreground, background, or killed
- Respond to notification interactions
- Configure notification appearance and behavior
Types of Notifications
Mobile notifications come in two flavors, each with different use cases and technical requirements.
π± Notification Comparison
| Aspect | Push | Local |
|---|---|---|
| Origin | Remote server | Device itself |
| Network | Required | Not required |
| Timing | Sent anytime by server | Scheduled in advance |
| Setup | APNs/FCM credentials needed | No credentials needed |
| Works when killed | Yes | Yes |
Setup and Permissions
Installation
# Install expo-notifications
npx expo install expo-notifications expo-device expo-constants
Configuration
// app.json
{
"expo": {
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#667eea",
"sounds": ["./assets/notification-sound.wav"]
}
]
],
"android": {
"useNextNotificationsApi": true
}
}
}
Request Permission
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
async function requestNotificationPermission(): Promise<boolean> {
// Notifications don't work on simulators
if (!Device.isDevice) {
console.log('Must use physical device for notifications');
return false;
}
// Check existing permission
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Request if not already granted
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Notification permission not granted');
return false;
}
// Android requires a notification channel
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#667eea',
});
}
return true;
}
Android Notification Channels
Android 8+ requires notification channels for categorizing notifications:
import * as Notifications from 'expo-notifications';
// Create multiple channels for different notification types
async function setupNotificationChannels() {
// Messages channel - high priority
await Notifications.setNotificationChannelAsync('messages', {
name: 'Messages',
description: 'New message notifications',
importance: Notifications.AndroidImportance.HIGH,
sound: 'message.wav',
vibrationPattern: [0, 250, 250, 250],
lightColor: '#2196F3',
});
// Updates channel - default priority
await Notifications.setNotificationChannelAsync('updates', {
name: 'Updates',
description: 'App updates and news',
importance: Notifications.AndroidImportance.DEFAULT,
sound: null,
});
// Reminders channel
await Notifications.setNotificationChannelAsync('reminders', {
name: 'Reminders',
description: 'Scheduled reminders',
importance: Notifications.AndroidImportance.HIGH,
sound: 'reminder.wav',
});
}
// List all channels
async function listChannels() {
const channels = await Notifications.getNotificationChannelsAsync();
console.log('Channels:', channels);
}
// Delete a channel
async function deleteChannel(channelId: string) {
await Notifications.deleteNotificationChannelAsync(channelId);
}
β οΈ Android Channel Importance
- MAX: Makes sound, shows heads-up notification
- HIGH: Makes sound, shows in status bar
- DEFAULT: Makes sound
- LOW: No sound
- MIN: No sound, doesn't appear in status bar
Push Tokens
To send push notifications to a device, you need its unique push token. Expo provides a convenient way to get tokens that work with their push notification service.
Getting the Expo Push Token
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
async function getExpoPushToken(): Promise<string | null> {
// Must be a physical device
if (!Device.isDevice) {
console.log('Push notifications require a physical device');
return null;
}
// Request permission first
const { status } = await Notifications.getPermissionsAsync();
if (status !== 'granted') {
const { status: newStatus } = await Notifications.requestPermissionsAsync();
if (newStatus !== 'granted') {
return null;
}
}
// Get the token
try {
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = await Notifications.getExpoPushTokenAsync({
projectId,
});
console.log('Expo Push Token:', token.data);
return token.data;
// Returns something like: "ExponentPushToken[xxxxxxxxxxxxxx]"
} catch (error) {
console.error('Error getting push token:', error);
return null;
}
}
Saving Token to Your Server
interface DeviceInfo {
token: string;
platform: 'ios' | 'android';
userId?: string;
}
async function registerDeviceForNotifications(userId: string) {
const token = await getExpoPushToken();
if (!token) {
console.log('Could not get push token');
return;
}
const deviceInfo: DeviceInfo = {
token,
platform: Platform.OS as 'ios' | 'android',
userId,
};
// Send to your backend
try {
await fetch('https://your-api.com/register-device', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(deviceInfo),
});
console.log('Device registered for notifications');
} catch (error) {
console.error('Failed to register device:', error);
}
}
Token Refresh Handling
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
function useNotificationToken(onTokenReceived: (token: string) => void) {
useEffect(() => {
// Get initial token
getExpoPushToken().then(token => {
if (token) onTokenReceived(token);
});
// Listen for token changes (rare, but possible)
const subscription = Notifications.addPushTokenListener(({ data }) => {
onTokenReceived(data);
});
return () => subscription.remove();
}, [onTokenReceived]);
}
sequenceDiagram
participant App
participant Expo
participant APNs/FCM
participant Server
App->>Expo: Request push token
Expo->>APNs/FCM: Register device
APNs/FCM-->>Expo: Device token
Expo-->>App: Expo Push Token
App->>Server: Save token with userId
Server-->>App: Token saved
Note over Server: Later, when sending notification
Server->>Expo: Send to push token
Expo->>APNs/FCM: Forward notification
APNs/FCM->>App: Deliver notification
Local Notifications
Local notifications are scheduled directly on the device without needing a server. They're perfect for reminders, alarms, and timer completions.
Send Immediate Notification
import * as Notifications from 'expo-notifications';
async function sendLocalNotification() {
await Notifications.scheduleNotificationAsync({
content: {
title: 'Hello! π',
body: 'This is a local notification',
data: { screen: 'home', id: '123' },
},
trigger: null, // null = send immediately
});
}
// With more options
async function sendRichNotification() {
await Notifications.scheduleNotificationAsync({
content: {
title: 'New Message',
subtitle: 'From John Doe',
body: 'Hey, are you free for lunch today?',
data: {
type: 'message',
senderId: 'user-123',
conversationId: 'conv-456',
},
sound: 'default',
badge: 1,
// iOS specific
launchImageName: 'notification-image',
// Android specific
color: '#667eea',
priority: Notifications.AndroidNotificationPriority.HIGH,
},
trigger: null,
});
}
Notification Content Options
π Content Properties
const content = {
// Required
title: 'Notification Title',
body: 'Notification body text',
// Optional
subtitle: 'Subtitle (iOS)',
data: { any: 'data' }, // Custom data for handling
sound: 'default', // or custom sound file
badge: 1, // App badge count
// iOS specific
launchImageName: 'image',
attachments: [{ url: 'file://...' }],
categoryIdentifier: 'category-id',
// Android specific
color: '#667eea',
priority: AndroidNotificationPriority.HIGH,
vibrationPattern: [0, 250, 250, 250],
autoDismiss: true,
sticky: false,
};
Scheduling Notifications
Notifications can be scheduled for future delivery using various trigger types.
Time-Based Trigger
import * as Notifications from 'expo-notifications';
// Schedule for X seconds from now
async function scheduleInSeconds(seconds: number) {
const id = await Notifications.scheduleNotificationAsync({
content: {
title: 'Reminder',
body: `${seconds} seconds have passed!`,
},
trigger: {
seconds,
repeats: false,
},
});
return id; // Save this to cancel later
}
// Schedule for a specific date/time
async function scheduleForDate(date: Date) {
await Notifications.scheduleNotificationAsync({
content: {
title: 'Scheduled Event',
body: 'Your event is starting now',
},
trigger: date,
});
}
// Schedule for tomorrow at 9 AM
async function scheduleForTomorrow9AM() {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
await Notifications.scheduleNotificationAsync({
content: {
title: 'Good Morning! βοΈ',
body: 'Time to start your day',
},
trigger: tomorrow,
});
}
Repeating Notifications
// Daily at specific time
async function scheduleDailyReminder(hour: number, minute: number) {
await Notifications.scheduleNotificationAsync({
content: {
title: 'Daily Reminder',
body: 'Don\'t forget to check in!',
},
trigger: {
hour,
minute,
repeats: true,
},
});
}
// Weekly on specific day
async function scheduleWeeklyReminder(weekday: number, hour: number, minute: number) {
// weekday: 1 = Sunday, 2 = Monday, etc.
await Notifications.scheduleNotificationAsync({
content: {
title: 'Weekly Reminder',
body: 'Time for your weekly review',
},
trigger: {
weekday,
hour,
minute,
repeats: true,
},
});
}
// Every X seconds (for testing)
async function scheduleRepeatingEvery(seconds: number) {
await Notifications.scheduleNotificationAsync({
content: {
title: 'Repeating',
body: 'This repeats every ' + seconds + ' seconds',
},
trigger: {
seconds,
repeats: true,
},
});
}
Calendar Trigger (Cron-like)
// More specific scheduling
async function scheduleMonthlyReminder() {
await Notifications.scheduleNotificationAsync({
content: {
title: 'Monthly Bill Reminder',
body: 'Your subscription renews tomorrow',
},
trigger: {
day: 1, // 1st of month
hour: 10,
minute: 0,
repeats: true,
},
});
}
// Specific date components
async function scheduleYearlyBirthday(month: number, day: number) {
await Notifications.scheduleNotificationAsync({
content: {
title: 'π Birthday Reminder',
body: 'Someone special has a birthday today!',
},
trigger: {
month, // 1-12
day, // 1-31
hour: 9,
minute: 0,
repeats: true,
},
});
}
Managing Scheduled Notifications
// Get all scheduled notifications
async function getScheduledNotifications() {
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
console.log('Scheduled:', scheduled);
return scheduled;
}
// Cancel a specific notification
async function cancelNotification(notificationId: string) {
await Notifications.cancelScheduledNotificationAsync(notificationId);
}
// Cancel all scheduled notifications
async function cancelAllNotifications() {
await Notifications.cancelAllScheduledNotificationsAsync();
}
// Example: Schedule with ability to cancel
async function scheduleReminder(title: string, body: string, triggerDate: Date) {
const id = await Notifications.scheduleNotificationAsync({
content: { title, body },
trigger: triggerDate,
});
// Return ID so caller can cancel if needed
return id;
}
// Cancel by ID
const reminderId = await scheduleReminder('Meeting', 'Team sync in 15 min', meetingDate);
// Later...
await cancelNotification(reminderId);
Handling Notifications
Your app needs to handle notifications in three scenarios: foreground, background, and when the user taps on a notification.
Configure Foreground Behavior
import * as Notifications from 'expo-notifications';
// Set how notifications behave when app is in foreground
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true, // Show notification alert
shouldPlaySound: true, // Play sound
shouldSetBadge: true, // Update app badge
}),
});
// You can also make this dynamic
Notifications.setNotificationHandler({
handleNotification: async (notification) => {
// Get notification data
const data = notification.request.content.data;
// Example: Don't show if user is already viewing that screen
if (data.screen === currentScreen) {
return {
shouldShowAlert: false,
shouldPlaySound: false,
shouldSetBadge: false,
};
}
return {
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
};
},
});
Listen for Notifications
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { useNavigation } from '@react-navigation/native';
export function useNotificationListeners() {
const navigation = useNavigation();
const notificationListener = useRef<Notifications.Subscription>();
const responseListener = useRef<Notifications.Subscription>();
useEffect(() => {
// Listener for when notification is received while app is foregrounded
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification) => {
console.log('Notification received:', notification);
const { title, body, data } = notification.request.content;
// You could update UI, show in-app alert, etc.
}
);
// Listener for when user taps on notification
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
console.log('Notification tapped:', response);
const data = response.notification.request.content.data;
// Navigate based on notification data
if (data.screen) {
navigation.navigate(data.screen as never, data.params as never);
}
}
);
// Cleanup
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(notificationListener.current);
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current);
}
};
}, [navigation]);
}
Handle App Launch from Notification
import * as Notifications from 'expo-notifications';
// Check if app was opened from a notification
async function getInitialNotification() {
// Get the notification that launched the app (if any)
const response = await Notifications.getLastNotificationResponseAsync();
if (response) {
console.log('App opened from notification:', response);
const data = response.notification.request.content.data;
return data;
}
return null;
}
// Use in your app initialization
function App() {
const navigation = useNavigation();
useEffect(() => {
getInitialNotification().then(data => {
if (data?.screen) {
// Navigate to the screen from notification
navigation.navigate(data.screen, data.params);
}
});
Customization
Customize how your notifications look and behave on different platforms.
Custom Notification Icon (Android)
// app.json - configure the notification icon
{
"expo": {
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#667eea"
}
]
]
}
}
π± Android Icon Requirements
- 96x96 pixels (or provide multiple densities)
- Must be white with transparent background
- Android will colorize it with the
color setting
- PNG format recommended
Custom Sounds
// app.json - register custom sounds
{
"expo": {
"plugins": [
[
"expo-notifications",
{
"sounds": [
"./assets/sounds/message.wav",
"./assets/sounds/alert.wav",
"./assets/sounds/reminder.wav"
]
}
]
]
}
}
// Use custom sound in notification
await Notifications.scheduleNotificationAsync({
content: {
title: 'Custom Sound',
body: 'This plays a custom sound!',
sound: 'message.wav', // Just the filename
},
trigger: null,
});
Notification Categories (iOS Actions)
import * as Notifications from 'expo-notifications';
// Set up notification categories with actions
async function setupNotificationCategories() {
await Notifications.setNotificationCategoryAsync('message', [
{
identifier: 'reply',
buttonTitle: 'Reply',
textInput: {
submitButtonTitle: 'Send',
placeholder: 'Type your reply...',
},
},
{
identifier: 'mark-read',
buttonTitle: 'Mark as Read',
},
{
identifier: 'delete',
buttonTitle: 'Delete',
options: {
isDestructive: true,
},
},
]);
await Notifications.setNotificationCategoryAsync('reminder', [
{
identifier: 'snooze',
buttonTitle: 'Snooze 10 min',
},
{
identifier: 'complete',
buttonTitle: 'Done',
},
]);
}
// Send notification with category
await Notifications.scheduleNotificationAsync({
content: {
title: 'New Message',
body: 'Hey, what\'s up?',
categoryIdentifier: 'message', // Uses the category actions
},
trigger: null,
});
// Handle action responses
Notifications.addNotificationResponseReceivedListener((response) => {
const actionId = response.actionIdentifier;
const userText = response.userText; // For text input actions
switch (actionId) {
case 'reply':
console.log('User replied:', userText);
// Send reply to server
break;
case 'mark-read':
console.log('Marked as read');
break;
case 'snooze':
// Reschedule notification
break;
}
});
Badge Management
import * as Notifications from 'expo-notifications';
// Set badge count
async function setBadgeCount(count: number) {
await Notifications.setBadgeCountAsync(count);
}
// Get current badge count
async function getBadgeCount() {
return await Notifications.getBadgeCountAsync();
}
// Clear badge
async function clearBadge() {
await Notifications.setBadgeCountAsync(0);
}
// Increment badge
async function incrementBadge() {
const current = await getBadgeCount();
await setBadgeCount(current + 1);
}
// Common pattern: Clear badge when app becomes active
import { useEffect } from 'react';
import { AppState } from 'react-native';
function useClearBadgeOnFocus() {
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
clearBadge();
}
});
return () => subscription.remove();
}, []);
}
Dismiss Notifications
// Dismiss a specific notification from the notification center
async function dismissNotification(notificationId: string) {
await Notifications.dismissNotificationAsync(notificationId);
}
// Dismiss all notifications
async function dismissAllNotifications() {
await Notifications.dismissAllNotificationsAsync();
}
// Get presented notifications (in notification center)
async function getPresentedNotifications() {
const notifications = await Notifications.getPresentedNotificationsAsync();
return notifications;
}
Sending Push Notifications
To send push notifications, your server needs to make requests to Expo's push notification service (or directly to APNs/FCM for production apps).
Using Expo Push Service
Expo provides a free push notification service that simplifies sending to both iOS and Android:
// Server-side code (Node.js example)
const { Expo } = require('expo-server-sdk');
const expo = new Expo();
async function sendPushNotification(pushToken: string, title: string, body: string, data?: object) {
// Check that the push token is valid
if (!Expo.isExpoPushToken(pushToken)) {
console.error('Invalid Expo push token:', pushToken);
return;
}
const message = {
to: pushToken,
sound: 'default',
title,
body,
data: data || {},
};
try {
const chunks = expo.chunkPushNotifications([message]);
for (const chunk of chunks) {
const ticketChunk = await expo.sendPushNotificationsAsync(chunk);
console.log('Ticket:', ticketChunk);
}
} catch (error) {
console.error('Error sending notification:', error);
}
}
// Send to multiple users
async function sendToMultipleUsers(tokens: string[], title: string, body: string) {
const messages = tokens
.filter(token => Expo.isExpoPushToken(token))
.map(token => ({
to: token,
sound: 'default',
title,
body,
}));
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
await expo.sendPushNotificationsAsync(chunk);
}
}
Direct HTTP Request
// You can also send directly via HTTP
async function sendNotificationHTTP(pushToken: string, title: string, body: string) {
const response = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: pushToken,
sound: 'default',
title,
body,
data: { someData: 'goes here' },
}),
});
const result = await response.json();
console.log('Push result:', result);
}
Push Message Options
π€ Push Message Properties
{
// Required
to: 'ExponentPushToken[xxx]',
// Content
title: 'Notification Title',
body: 'Notification body text',
data: { /* custom data */ },
// Behavior
sound: 'default', // or null for silent
badge: 1, // iOS badge count
ttl: 3600, // Seconds until expiry
expiration: 1234567890, // Unix timestamp
priority: 'high', // 'default' | 'normal' | 'high'
// Android specific
channelId: 'default',
// iOS specific
subtitle: 'Subtitle',
_displayInForeground: true,
}
Complete Notification Service
// notification-service.ts - Complete client-side setup
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
// Configure foreground behavior
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
class NotificationService {
private static instance: NotificationService;
private notificationListener?: Notifications.Subscription;
private responseListener?: Notifications.Subscription;
static getInstance() {
if (!this.instance) {
this.instance = new NotificationService();
}
return this.instance;
}
async initialize(onNotificationReceived?: (notification: Notifications.Notification) => void,
onNotificationResponse?: (response: Notifications.NotificationResponse) => void) {
// Request permissions
const hasPermission = await this.requestPermission();
if (!hasPermission) {
console.log('Notification permission denied');
return null;
}
// Get token
const token = await this.getToken();
// Set up listeners
if (onNotificationReceived) {
this.notificationListener = Notifications.addNotificationReceivedListener(onNotificationReceived);
}
if (onNotificationResponse) {
this.responseListener = Notifications.addNotificationResponseReceivedListener(onNotificationResponse);
}
return token;
}
async requestPermission(): Promise<boolean> {
if (!Device.isDevice) {
return false;
}
const { status } = await Notifications.requestPermissionsAsync();
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.MAX,
});
}
return status === 'granted';
}
async getToken(): Promise<string | null> {
if (!Device.isDevice) {
return null;
}
try {
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
return data;
} catch (error) {
console.error('Error getting push token:', error);
return null;
}
}
async scheduleLocal(title: string, body: string, trigger: Notifications.NotificationTriggerInput, data?: object) {
return await Notifications.scheduleNotificationAsync({
content: { title, body, data: data || {} },
trigger,
});
}
async cancelAll() {
await Notifications.cancelAllScheduledNotificationsAsync();
}
async clearBadge() {
await Notifications.setBadgeCountAsync(0);
}
cleanup() {
this.notificationListener?.remove();
this.responseListener?.remove();
}
}
export const notificationService = NotificationService.getInstance();
// Usage in App.tsx
import { useEffect } from 'react';
import { notificationService } from './notification-service';
function App() {
useEffect(() => {
notificationService.initialize(
(notification) => {
console.log('Received:', notification);
},
(response) => {
console.log('Tapped:', response);
// Handle navigation based on response.notification.request.content.data
}
);
return () => notificationService.cleanup();
}, []);
return <YourApp />;
}
Hands-On Exercises
Exercise 1: Reminder App
Build a simple reminder app that schedules local notifications.
Requirements:
- Text input for reminder message
- Date/time picker for when to remind
- Schedule button that creates the notification
- List of scheduled reminders with cancel option
Show Hint
Use Notifications.scheduleNotificationAsync with a Date trigger. Store notification IDs in state to display the list. Use Notifications.cancelScheduledNotificationAsync to cancel individual reminders.
Show Solution
import { useState, useEffect } from 'react';
import { View, Text, TextInput, Pressable, FlatList, StyleSheet, Alert } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import * as Notifications from 'expo-notifications';
interface Reminder {
id: string;
message: string;
date: Date;
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export default function ReminderApp() {
const [message, setMessage] = useState('');
const [date, setDate] = useState(new Date());
const [showPicker, setShowPicker] = useState(false);
const [reminders, setReminders] = useState<Reminder[]>([]);
useEffect(() => {
requestPermission();
loadScheduledReminders();
}, []);
const requestPermission = async () => {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Required', 'Please enable notifications');
}
};
const loadScheduledReminders = async () => {
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
const loaded = scheduled.map(n => ({
id: n.identifier,
message: n.content.body || '',
date: new Date(n.trigger?.value || Date.now()),
}));
setReminders(loaded);
};
const scheduleReminder = async () => {
if (!message.trim()) {
Alert.alert('Error', 'Please enter a reminder message');
return;
}
if (date <= new Date()) {
Alert.alert('Error', 'Please select a future date');
return;
}
const id = await Notifications.scheduleNotificationAsync({
content: {
title: 'β° Reminder',
body: message,
sound: 'default',
},
trigger: date,
});
setReminders(prev => [...prev, { id, message, date }]);
setMessage('');
setDate(new Date());
};
const cancelReminder = async (id: string) => {
await Notifications.cancelScheduledNotificationAsync(id);
setReminders(prev => prev.filter(r => r.id !== id));
};
const formatDate = (d: Date) => {
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
return (
<View style={styles.container}>
<Text style={styles.title}>β° Reminders</Text>
<TextInput
style={styles.input}
placeholder="What do you want to remember?"
value={message}
onChangeText={setMessage}
/>
<Pressable style={styles.dateButton} onPress={() => setShowPicker(true)}>
<Text style={styles.dateButtonText}>π
{formatDate(date)}</Text>
</Pressable>
{showPicker && (
<DateTimePicker
value={date}
mode="datetime"
minimumDate={new Date()}
onChange={(event, selectedDate) => {
setShowPicker(false);
if (selectedDate) setDate(selectedDate);
}}
/>
)}
<Pressable style={styles.scheduleButton} onPress={scheduleReminder}>
<Text style={styles.scheduleButtonText}>Schedule Reminder</Text>
</Pressable>
<Text style={styles.listTitle}>Upcoming Reminders</Text>
<FlatList
data={reminders.sort((a, b) => a.date.getTime() - b.date.getTime())}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.reminderItem}>
<View style={styles.reminderInfo}>
<Text style={styles.reminderMessage}>{item.message}</Text>
<Text style={styles.reminderDate}>{formatDate(item.date)}</Text>
</View>
<Pressable onPress={() => cancelReminder(item.id)}>
<Text style={styles.cancelText}>β</Text>
</Pressable>
</View>
)}
ListEmptyComponent={<Text style={styles.emptyText}>No reminders scheduled</Text>}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
title: { fontSize: 28, fontWeight: 'bold', marginBottom: 20 },
input: { backgroundColor: '#f5f5f5', padding: 16, borderRadius: 8, fontSize: 16, marginBottom: 12 },
dateButton: { backgroundColor: '#e3f2fd', padding: 16, borderRadius: 8, marginBottom: 12 },
dateButtonText: { fontSize: 16, textAlign: 'center' },
scheduleButton: { backgroundColor: '#667eea', padding: 16, borderRadius: 8, marginBottom: 24 },
scheduleButtonText: { color: 'white', fontSize: 16, fontWeight: '600', textAlign: 'center' },
listTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
reminderItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: '#f5f5f5', padding: 16, borderRadius: 8, marginBottom: 8 },
reminderInfo: { flex: 1 },
reminderMessage: { fontSize: 16, fontWeight: '500' },
reminderDate: { fontSize: 12, color: '#666', marginTop: 4 },
cancelText: { fontSize: 20, color: '#f44336', padding: 8 },
emptyText: { color: '#999', textAlign: 'center', marginTop: 20 },
});
Exercise 2: Notification Tester
Build a notification testing tool that demonstrates different notification types.
Requirements:
- Button to send immediate notification
- Button to send notification in 5 seconds
- Button to send notification with custom data
- Display received notification content
Show Hint
Use addNotificationReceivedListener to capture incoming notifications. Display the notification content in state. Use trigger: null for immediate and trigger: { seconds: 5 } for delayed.
Show Solution
import { useState, useEffect, useRef } from 'react';
import { View, Text, Pressable, ScrollView, StyleSheet } from 'react-native';
import * as Notifications from 'expo-notifications';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export default function NotificationTester() {
const [lastNotification, setLastNotification] = useState<Notifications.Notification | null>(null);
const [lastResponse, setLastResponse] = useState<Notifications.NotificationResponse | null>(null);
const notificationListener = useRef<Notifications.Subscription>();
const responseListener = useRef<Notifications.Subscription>();
useEffect(() => {
Notifications.requestPermissionsAsync();
notificationListener.current = Notifications.addNotificationReceivedListener(
notification => setLastNotification(notification)
);
responseListener.current = Notifications.addNotificationResponseReceivedListener(
response => setLastResponse(response)
);
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
};
}, []);
const sendImmediate = async () => {
await Notifications.scheduleNotificationAsync({
content: {
title: 'π Immediate Notification',
body: 'This was sent right away!',
data: { type: 'immediate', timestamp: Date.now() },
},
trigger: null,
});
};
const sendDelayed = async () => {
await Notifications.scheduleNotificationAsync({
content: {
title: 'β° Delayed Notification',
body: 'This was sent after 5 seconds',
data: { type: 'delayed', timestamp: Date.now() },
},
trigger: { seconds: 5 },
});
};
const sendWithData = async () => {
await Notifications.scheduleNotificationAsync({
content: {
title: 'π¦ Data Notification',
body: 'This notification includes custom data',
data: {
type: 'custom',
screen: 'profile',
userId: 'user-123',
metadata: { score: 100, level: 5 },
},
},
trigger: null,
});
};
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>π§ͺ Notification Tester</Text>
<View style={styles.buttonGroup}>
<Pressable style={styles.button} onPress={sendImmediate}>
<Text style={styles.buttonText}>Send Immediate</Text>
</Pressable>
<Pressable style={[styles.button, styles.delayedButton]} onPress={sendDelayed}>
<Text style={styles.buttonText}>Send in 5 sec</Text>
</Pressable>
<Pressable style={[styles.button, styles.dataButton]} onPress={sendWithData}>
<Text style={styles.buttonText}>Send with Data</Text>
</Pressable>
</View>
<Text style={styles.sectionTitle}>Last Received Notification</Text>
{lastNotification ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>{lastNotification.request.content.title}</Text>
<Text style={styles.cardBody}>{lastNotification.request.content.body}</Text>
<Text style={styles.cardData}>
Data: {JSON.stringify(lastNotification.request.content.data, null, 2)}
</Text>
</View>
) : (
<Text style={styles.emptyText}>No notifications received yet</Text>
)}
<Text style={styles.sectionTitle}>Last Tapped Notification</Text>
{lastResponse ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>{lastResponse.notification.request.content.title}</Text>
<Text style={styles.cardData}>
Action: {lastResponse.actionIdentifier}
</Text>
<Text style={styles.cardData}>
Data: {JSON.stringify(lastResponse.notification.request.content.data, null, 2)}
</Text>
</View>
) : (
<Text style={styles.emptyText}>No notifications tapped yet</Text>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
title: { fontSize: 28, fontWeight: 'bold', marginBottom: 24 },
buttonGroup: { marginBottom: 32 },
button: { backgroundColor: '#667eea', padding: 16, borderRadius: 8, marginBottom: 12 },
delayedButton: { backgroundColor: '#FF9800' },
dataButton: { backgroundColor: '#4CAF50' },
buttonText: { color: 'white', fontSize: 16, fontWeight: '600', textAlign: 'center' },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
card: { backgroundColor: '#f5f5f5', padding: 16, borderRadius: 8, marginBottom: 24 },
cardTitle: { fontSize: 16, fontWeight: '600', marginBottom: 8 },
cardBody: { fontSize: 14, marginBottom: 8 },
cardData: { fontSize: 12, color: '#666', fontFamily: 'monospace' },
emptyText: { color: '#999', fontStyle: 'italic', marginBottom: 24 },
});
Exercise 3: Daily Streak Reminder
Build a daily streak app that reminds users to check in each day.
Requirements:
- Display current streak count
- Check-in button that increments streak
- Toggle to enable/disable daily reminder
- Time picker for when to send the reminder
- Reminder should repeat daily at chosen time
Show Hint
Use trigger: { hour, minute, repeats: true } for daily repeating notifications. Store streak count and reminder settings in AsyncStorage. Cancel and reschedule when time changes.
Show Solution
import { useState, useEffect } from 'react';
import { View, Text, Pressable, Switch, StyleSheet } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Notifications from 'expo-notifications';
const STREAK_KEY = 'streak';
const LAST_CHECKIN_KEY = 'lastCheckin';
const REMINDER_ID_KEY = 'reminderId';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export default function StreakReminder() {
const [streak, setStreak] = useState(0);
const [checkedInToday, setCheckedInToday] = useState(false);
const [reminderEnabled, setReminderEnabled] = useState(false);
const [reminderTime, setReminderTime] = useState(new Date());
const [showTimePicker, setShowTimePicker] = useState(false);
useEffect(() => {
loadData();
Notifications.requestPermissionsAsync();
}, []);
const loadData = async () => {
const savedStreak = await AsyncStorage.getItem(STREAK_KEY);
const lastCheckin = await AsyncStorage.getItem(LAST_CHECKIN_KEY);
const reminderId = await AsyncStorage.getItem(REMINDER_ID_KEY);
if (savedStreak) setStreak(parseInt(savedStreak));
if (reminderId) setReminderEnabled(true);
// Check if already checked in today
if (lastCheckin) {
const lastDate = new Date(lastCheckin).toDateString();
const today = new Date().toDateString();
setCheckedInToday(lastDate === today);
// Reset streak if missed a day
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
if (lastDate !== today && lastDate !== yesterday.toDateString()) {
setStreak(0);
await AsyncStorage.setItem(STREAK_KEY, '0');
}
}
};
const handleCheckin = async () => {
if (checkedInToday) return;
const newStreak = streak + 1;
setStreak(newStreak);
setCheckedInToday(true);
await AsyncStorage.setItem(STREAK_KEY, newStreak.toString());
await AsyncStorage.setItem(LAST_CHECKIN_KEY, new Date().toISOString());
};
const toggleReminder = async (enabled: boolean) => {
setReminderEnabled(enabled);
if (enabled) {
await scheduleReminder();
} else {
await cancelReminder();
}
};
const scheduleReminder = async () => {
await cancelReminder();
const id = await Notifications.scheduleNotificationAsync({
content: {
title: 'π₯ Don\'t break your streak!',
body: `You have a ${streak} day streak. Check in to keep it going!`,
sound: 'default',
},
trigger: {
hour: reminderTime.getHours(),
minute: reminderTime.getMinutes(),
repeats: true,
},
});
await AsyncStorage.setItem(REMINDER_ID_KEY, id);
};
const cancelReminder = async () => {
const id = await AsyncStorage.getItem(REMINDER_ID_KEY);
if (id) {
await Notifications.cancelScheduledNotificationAsync(id);
await AsyncStorage.removeItem(REMINDER_ID_KEY);
}
};
const handleTimeChange = async (event: any, selectedTime?: Date) => {
setShowTimePicker(false);
if (selectedTime) {
setReminderTime(selectedTime);
if (reminderEnabled) {
await scheduleReminder();
}
}
};
return (
<View style={styles.container}>
<Text style={styles.streakNumber}>{streak}</Text>
<Text style={styles.streakLabel}>Day Streak π₯</Text>
<Pressable
style={[styles.checkinButton, checkedInToday && styles.checkinButtonDone]}
onPress={handleCheckin}
disabled={checkedInToday}
>
<Text style={styles.checkinButtonText}>
{checkedInToday ? 'β Checked In Today' : 'Check In'}
</Text>
</Pressable>
<View style={styles.reminderSection}>
<Text style={styles.sectionTitle}>Daily Reminder</Text>
<View style={styles.reminderRow}>
<Text>Enable Reminder</Text>
<Switch value={reminderEnabled} onValueChange={toggleReminder} />
</View>
{reminderEnabled && (
<Pressable style={styles.timeButton} onPress={() => setShowTimePicker(true)}>
<Text>Remind me at: {reminderTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</Text>
</Pressable>
)}
{showTimePicker && (
<DateTimePicker
value={reminderTime}
mode="time"
onChange={handleTimeChange}
/>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, alignItems: 'center', justifyContent: 'center' },
streakNumber: { fontSize: 80, fontWeight: 'bold', color: '#667eea' },
streakLabel: { fontSize: 24, color: '#666', marginBottom: 40 },
checkinButton: { backgroundColor: '#4CAF50', paddingHorizontal: 48, paddingVertical: 16, borderRadius: 30 },
checkinButtonDone: { backgroundColor: '#ccc' },
checkinButtonText: { color: 'white', fontSize: 18, fontWeight: '600' },
reminderSection: { marginTop: 60, width: '100%' },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 16 },
reminderRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 },
timeButton: { padding: 16, backgroundColor: '#f5f5f5', borderRadius: 8 },
});
Summary
Notifications are a powerful way to keep users engaged with your app. Use them wiselyβtoo many notifications drive users away, while well-timed, relevant notifications keep them coming back.
π― Key Takeaways
- Push vs Local: Push comes from servers, local is scheduled on-device
- Permissions: Always request before sending notifications
- Expo Push Token: Unique identifier for sending push to a device
- Android Channels: Required for Android 8+, control notification behavior
- Triggers: Immediate (null), time-based (seconds), calendar (hour/minute)
- Foreground handler: Control if notifications show when app is open
- Response listener: Handle when user taps a notification
- Badge management: Keep badge count in sync with actual notifications
- Respect users: Let them control notification preferences
In the next lesson, we'll explore device sensorsβaccelerometer, gyroscope, and other motion sensors that enable unique interactive experiences.