In-app reminder notifications with React Native Expo

In-app reminder notifications with React Native Expo
A dragon receiving a notification in the style of Francis Bacon (stable diffusion)

I've been working on a notifications reminders screen for my new CBT clinic app. The user with get reminders to complete thought exercises and also submit reports on overall well-being as well as session feedback reports. The overall progress report and the session progress report reminders will be triggered once a week, while the thought exercise report will be triggered daily or once every second/third day depending on the users preferences.

Scheduled notifications with expo-notifications

Install expo-notifications library: expo install expo-notifications

I'm using Typescript in my app heres how i've defined the Reminder type which will be used for storing/scheduling the notifications.

export type ReminderType = "daily" | "weekly" | "every_other_day";
export type Reminder = {
  type: ReminderType;
  title: string;
  subTitle: string;
  // used for preselecting the dropdown option in UI
  optionKey: string;
  trigger: {
    hours: number;
    minutes: number;
    repeats?: boolean;
    weekday?: number;
    daysInterval?: number;
  },
  // notification identifier
  id?: string;
  // used for launch functionality when opening notification
  data?: any;
}

Heres my functions for daily, weekly and time interval based notification triggers.

Daily reminder

const schduleDailyReminder = (reminder: Reminder) => {
  return Notifications.scheduleNotificationAsync({
    content: {
      title: reminder.title,
      body: reminder.subTitle,
      data: reminder.data
    },
    trigger: {
      hour: reminder.trigger.hours,
      minute: reminder.trigger.minutes,
      repeats: true,
    },
  })
}

title and body are used in the notification display. We will used the data field in the notification handler to decide what screen to navigate to later. The trigger object says hour, minute of the day notification triggers and wether it repeats at the same time every day.

Weekly reminder

const schduleWeeklyReminder = (reminder: Reminder) => {
  return Notifications.scheduleNotificationAsync({
    content: {
      title: reminder.title,
      body: reminder.subTitle,
      data: reminder.data,
    },
    trigger: {
      hour: reminder.trigger.hours,
      minute: reminder.trigger.minutes,
      weekday: reminder.trigger.weekday,
      repeats: true
    },
  });
}

The weekday field here ranges from 1-7 with Sunday being 1.

Time interval reminder (once every 2 or 3 days)

For scheduling time interval notifications, the interval defined in seconds. So for once every 2 days, we can get 2 or 3 days in seconds let seconds = dayInSeconds * reminder.trigger.daysInterval, plus or minus the difference between the current day's time in seconds and the selected hours/minutes in seconds. Theres probably a nicer way to do this with Date object but I wanted to keep everything calculated in seconds.

const schduleEveryOtherDayReminder = (reminder: Reminder) => {

  // TODO: Calculate seconds from daysInterval
  const dayInSeconds = 86400;
  // @ts-ignore
  let seconds = dayInSeconds * reminder.trigger.daysInterval;

  // Get the diff in seconds between 24hr current time
  // and the 24hr time of the reminder
  const selectedHourMinuteInSeconds = (reminder.trigger.hours * 3600) + (reminder.trigger.minutes * 60);
  const currentHourMinuteInSeconds = (new Date().getHours() * 3600) + (new Date().getMinutes() * 60);
  const diff = selectedHourMinuteInSeconds - currentHourMinuteInSeconds;
  seconds = seconds + diff;

  return Notifications.scheduleNotificationAsync({
    content: {
      title: reminder.title,
      body: reminder.subTitle,
      data: reminder.data,
    },
    trigger: {
      seconds,
      repeats: true
    },
  });
}

Cancelling reminders

const cancelReminder = (identifier: string) => {
  return Notifications.cancelScheduledNotificationAsync(identifier)
}

Redux middleware to handle scheduling

I've set up the notification scheduling above to be called in redux middleware. If you're unfamiliar with redux, the idea is that when dispatching actions (requests to update the global state of your application) you can run some code before the actions reaches your reducers (which defines how the state will change based of the action dispatched). This keeps all the logic for scheduling notifications nicely tucked away from the state updates. So when adding a reminder we can just dispatch an action like this:

dispatch(RemindersSlice.actions.setThoughtExcerciseReminder({
    title: "💭 Thought Excercise",
    subTitle: "Remember to complete a thought excerise today",
    optionKey: reminder.option.key,
    data: { notif_type: "THOUGHT_EXCERCISE_REMINDER", notif_data: {}},
    type: reminder.option.type,
    trigger: {
        hours: parseInt(reminder.hours),
        minutes: parseInt(reminder.minutes),
        repeats: true,
        daysInterval: reminder.option.value
    }
 }));

And then in the middleware we can listen for the Reminders/setThoughtExcerciseReminder action. First we check to see if there is already a reminder setup for thought exercises in our application state. If there is we delete it using the notification ID.

export const notifications =
  (store: any) => (next: any) => async (action: PayloadAction<Reminder>) => {
    try {
      switch (action.type) {
        case "Reminders/setThoughtExcerciseReminder": {
          const previousReminderId = store.getState().Reminders.thought_excercise?.id;
          if (previousReminderId) {
            await cancelReminder(previousReminderId);
          }
          if (action.payload.type === "daily") {
            const notificationId = await schduleDailyReminder(action.payload);
            if (notificationId) {
              action.payload.id = notificationId;
              next(action)
              break;
            } else {
              throw new Error("Notification ID is null")
            }
          } else {
            const notificationId = await schduleEveryOtherDayReminder(action.payload);
            if (notificationId) {
              action.payload.id = notificationId;
              next(action)
              break;
            } else {
              throw new Error("Notification ID is null")
            }
          }
        }

We call schduleDailyReminder or schduleEveryOtherDayReminder depending on the reminder type daily or every_other_day. The next(action) is then called to pass the action on to the reducer which will store the reminder in application state.

If you'd like to take a look at the full middleware function, check it out here:

Redux middleware for adding notification reminders
Redux middleware for adding notification reminders - NotificationsMiddleware.ts

Handling opened reminder notifications

When the user clicks on one of our reminder notifications we want to decide which screen to navigate to:

  • Thought Excercise reminder -> AddThoughtModal screen
  • Session report reminder -> SessionRatingReport screen
  • Overall progress -> OverallProgressReport screen

This is how I've setup the root of my app.

<Provider store={store}>
    <PersistGate loading={null} persistor={persistor}>
        <NotificationWrapper>
            <OTAUpdateModal>
                <Root colorScheme={colorScheme} />
            </OTAUpdateModal>
        </NotificationWrapper>
    </PersistGate>
</Provider>

The <NotificationWrapper/> component will house all of the logic for handling notifications interactions. <Root /> contains our navigation container which we will get a reference to when navigating in <NotificationsWrapper/>. You can checkout the entire notifications wrapper component here:

NotificationsWrapper component for handling notifications setup and interactions in React Native Expo
NotificationsWrapper component for handling notifications setup and interactions in React Native Expo - NotificationsWrapper.ts

A tricky thing with handling notifications in a wrapper component like is that the navigation container may not be initialised in our app yet when we look to call navigate. So we'll have to use a navigateWhenReady function.

const handleNotificationType = (notif_type: string, notif_data: any) => {
    switch (notif_type) {
        case "THOUGHT_COMMENT": {
            if (notif_data.thought_id) {
                navigateWhenReady("NewThoughtModal", { id: notif_data.thought_id });
            }
        break;
        }
        case "THOUGHT_EXCERCISE_REMINDER": {
            navigateWhenReady("NewThoughtModal")
            break;
        }
        case "OVERALL_PROGRESS_REPORT_REMINDER": {
            navigateWhenReady("OverallProgressReport")
            break;
        }
        case "SESSION_RATING_REPORT_REMINDER": {
            navigateWhenReady("SessionRatingReport")
            break;
        }
        default: {
            console.log()
            break;
        }
    }
}

In my app once a notification is clicked handleNotificationType is called passing notif_type and notif_data from the notification data. navigateWhenReady is called with the screen name corresponding to the notif_type. This will check weather the navigation container is ready to call navigate on and if not wait half a second and try again. This is the most reliable way i've found to navigate after a notification has been clicked, and with the app being previous killed i.e not in foreground or background on the users device. In my <Root/> component I've defined our navigation functions and also the navigation container.

export const navigationRef = createNavigationContainerRef()

export function navigate(name: string, params: any) {
  if (navigationRef.isReady()) {
    navigationRef.navigate(name, params);
  } else {
    console.log("navigate not ready")
  }
}

export const navigateWhenReady = (name: string, params?: any) => {
  if (navigationRef.isReady()) {
    return navigationRef.navigate(name, params);
  } else {
    console.log("navigate not ready")
    setTimeout(() => {
      navigateWhenReady(name, params);
    }, 500)
  }
}

export default function Root({
  colorScheme,
}: {
  colorScheme: ColorSchemeName;
}) {
  return (
    <NavigationContainer
      linking={LinkingConfiguration}
      ref={navigationRef}
      theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
    >
      <RootNavigator />
    </NavigationContainer>
  );
}

When the app is launched by opening a reminder notification navigateWhenReady will continue being called every half a second until navigation is ready and then once it is it will navigate to screen required.

Useful resources

I hope you've found this helpful for building a notification reminders in your expo app! 🚀 Here are some resources you might find useful: