In-app reminder notifications with React Native Expo
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:
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:
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: