Skip to main content

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.

Push vs Local Notifications Push Notifications πŸ–₯️ Your Server ☁️ APNs/FCM πŸ“± Device βœ“ New messages βœ“ Social updates βœ“ Breaking news βœ“ Order updates βœ“ Server-triggered alerts Local Notifications πŸ“± Device schedules locally No server needed βœ“ Reminders βœ“ Alarms βœ“ Calendar events βœ“ Timer completions βœ“ Offline app alerts

πŸ“± 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.

}, []); return <YourApp />; }
stateDiagram-v2
    [*] --> Foreground: App is open
    [*] --> Background: App in background
    [*] --> Killed: App not running
    
    Foreground --> Received: Notification arrives
    Received --> Handler: handleNotification called
    Handler --> Display: Show/hide based on config
    
    Background --> SystemTray: Shows in notification center
    Killed --> SystemTray: Shows in notification center
    
    SystemTray --> UserTap: User taps notification
    UserTap --> AppLaunch: Opens/resumes app
    AppLaunch --> ResponseListener: Handles navigation