Scheduling Local Notifications with expo-notifications to increase engagement on Dreamiary
I added local push notifications to my dream journal app to get my users build a habit and use the app more often.
No Firebase messaging needed, just pure local push notifications scheduled on the device.
I included 3 types of reminders in my app:
- Log a dream - Fires at a time the user picks. Nudges them to write down their dream before they forget.
- Reality checks - This one's for lucid dreamers. Quick context: a reality check is how you figure out that you're dreaming. For me it's stuff like I can't turn the light on or can't speak. That's the idea with reality checks reminder. Randomly throughout the day, check your reflection, check the lights, check if you can talk. If you make a habit to question reality in waking life, it will likely transfer to your dream.
- Weekly progress - Fires on a chosen day of the week and hour. Pulls users back in to review their progress.
Push notifications screen
Although I won't show code for my Dreamiary app, I created a demo repo that uses similar concepts. Every reminder type is backed by a small, reusable helper that lives in src/notifications. The screens just call those helpers with the right content and channel.
Prerequisites
I'm only doing local push notifications, so there's nothing to configure in the Apple Developer portal or the Play Console. Once the app schedules a notification, the system is responsible for delivering it. If you are interested into remote notifications, that's another story.
Install dependencies:
npx expo install expo-notifications @react-native-community/datetimepicker
Datetimepicker is needed for letting the user pick the exact hour and minute a reminder fires.
Expo App config
Add this to plugins in app.json:
'@react-native-community/datetimepicker',
[
'expo-notifications',
{
sounds: [],
},
],
You might want to tweak the configuration of your plugin, for full options consult the official doc.
On Android 12 (API 31) and up, if you want a notification to fire at a precise time you need the SCHEDULE_EXACT_ALARM permission. In a managed Expo project you declare it in the app config, not by hand-editing the manifest:
android: {
permissions: ['android.permission.SCHEDULE_EXACT_ALARM'],
},
One thing the Expo docs don't spell out: on Android 14+ this permission is denied by default for any app that isn't a clock or calendar app. A dream journal isn't, so on Android 14 my "fire at 14:02" reminder will silently fall back to an inexact alarm and drift unless the user enables "Alarms & reminders" for the app in system settings. If exact timing matters to you, then you should use USE_EXACT_ALARM. But, Google Play restricts it to apps whose core function is an alarm clock or a calendar.
A journal app declaring it risks rejection or removal at review.
If you're using proguard, then include expo notifications in the proguard exception rule:
-keep class expo.modules.notifications.** { *; }
Asking for Permission
Notifications won't be shown before the user allows them. On iOS you won't even see the Notifications in the app system settings.
The permission helpers all live in src/notifications/permissions.ts:
export type PermissionResult = { granted: boolean; canAskAgain: boolean };
export async function getPermissionStatus(): Promise<PermissionResult> {
const { status, canAskAgain } = await Notifications.getPermissionsAsync();
const granted = status === Notifications.PermissionStatus.GRANTED;
return { granted, canAskAgain };
}
export async function ensurePermissions(): Promise<PermissionResult> {
let { status, canAskAgain } = await Notifications.getPermissionsAsync();
if (status !== Notifications.PermissionStatus.GRANTED && canAskAgain) {
({ status, canAskAgain } = await Notifications.requestPermissionsAsync());
}
const granted = status === Notifications.PermissionStatus.GRANTED;
return { granted, canAskAgain };
}
export async function openNotificationSettings(): Promise<void> {
try {
await Linking.openSettings();
} catch (err) {
throw new Error('Could not open settings', { cause: err });
}
}
ensurePermissions is the one the UI calls when the user taps "Enable notifications".
The important detail is in there: the system prompt only appears once per install on iOS. After the user taps "Don't Allow", requestPermissionsAsync() quietly resolves as denied every time after that, and canAskAgain is false. The only way back is through Settings, which is why the home screen falls back to openNotificationSettings().
Since enabling permission means leaving the app, flipping a toggle in OS settings, and coming back, I re-check the status whenever the app returns to the foreground.
const appState = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => {
let active = true;
function refreshStatus() {
getPermissionStatus()
.then(({ granted }) => {
if (!active) return;
// update your state here...
})
.catch(() => {
/* keep last known status if the check fails */
});
}
refreshStatus();
const subscription = AppState.addEventListener('change', (nextAppState) => {
const wasBackgrounded = ['inactive', 'background'].includes(
appState.current,
);
if (wasBackgrounded && nextAppState === 'active') {
refreshStatus();
}
appState.current = nextAppState;
});
return () => {
active = false;
subscription.remove();
};
}, []);
Building Multiple Channels
On Android (API 26+) every notification has to belong to a channel. Channels are what let the user mute one kind of reminder without killing the rest, straight from system settings. iOS has no concept of channels, so creating them there is just a no-op and you can call the same setup code on both platforms.
The setup helper is generic, it just takes a list of channel configs:
export async function setupNotificationChannels(
channels: { id: string; name: string; importance?: Notifications.AndroidImportance }[],
) {
if (Platform.OS !== 'android') return; // no-op on iOS
await Promise.all(
channels.map((c) =>
Notifications.setNotificationChannelAsync(c.id, {
name: c.name,
importance: c.importance ?? Notifications.AndroidImportance.DEFAULT,
}),
),
);
}
I create one channel per reminder type, so muting a channel mutes exactly that reminder. The call lives in the root layout:
setupNotificationChannels([
{ id: 'daily', name: 'Daily reminders', importance: Notifications.AndroidImportance.HIGH },
{ id: 'weekly', name: 'Weekly reminders', importance: Notifications.AndroidImportance.HIGH },
{ id: 'window', name: 'Windowed reminders', importance: Notifications.AndroidImportance.HIGH },
]);
daily is the "Log a dream" channel, weekly is "Weekly progress", window is "Reality checks".
It's important that you call setupNotificationChannels(...) once at app start, before you schedule anything. If you schedule into a channel that doesn't exist yet, Android drops the notification.
And then you attach a notification to its channel through a channelId on the trigger.
Scheduling the reminders
Each reminder type maps to a different trigger. There's a dedicated helper per type in src/notifications, and they all share three habits: a stable identifier so re-scheduling replaces instead of stacking, a channelId so the notification lands in the right channel, and a data payload so a tap knows where to go.
Daily Trigger - log a dream
Log a dream is a daily reminder at the time the user picked, so it's a DAILY trigger. The helper cancels its own identifier first, then schedules:
export async function scheduleDaily(params: {
identifier: string;
channelId?: string;
hour: number;
minute: number;
content: { title: string; body: string };
data?: Record<string, unknown>;
}) {
await cancel(params.identifier); // replace, never stack
await Notifications.scheduleNotificationAsync({
identifier: params.identifier,
content: { ...params.content, data: params.data },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour: params.hour,
minute: params.minute,
channelId: params.channelId,
},
});
}
The dream-journal screen calls it like this:
await scheduleDaily({
identifier: 'log-dream',
channelId: 'daily',
hour,
minute,
content: { title: 'Log your dream', body: 'Write it down before it fades.' },
data: { screen: '/scheduled', kind: 'daily', label: 'Log a dream' },
});
Because the identifier is stable (log-dream) and the helper cancels it before re-scheduling, users can change the time as many times as they want and there's only ever one log a dream notification pending.
Weekly trigger - weekly progress
It's the same helper as scheduleDaily with one trigger swap. Also, in expo-notifications weekday is 1-7 where 1 is Sunday and 7 is Saturday:
trigger: {
type: Notifications.SchedulableTriggerInputTypes.WEEKLY,
weekday: params.weekday, // 1 = Sunday ... 7 = Saturday
hour: params.hour,
minute: params.minute,
channelId: params.channelId,
}
Example of a call:
await scheduleWeekly({
identifier: 'weekly-progress',
channelId: 'weekly',
weekday, // from the day picker, 1-7
hour,
minute,
content: { title: 'Your week in dreams', body: 'See how your recall has been trending.' },
data: { screen: '/scheduled', kind: 'weekly', label: 'Weekly progress' },
});
Reality Checks - I like this one
Reality checks are the odd one out. There's no "fire at random times all day" trigger in expo-notifications, so you have to build the behavior yourself.
I solved it by picking a handful of times spread across the user's waking window and schedule each one as a daily repeating trigger. The spreading lives in spreadMinutesOfDayInWindow. It splits the window into equal segments, aims at the center of each segment, then nudges it by a bounded random amount so the times look organic but never collide:
export const spreadMinutesOfDayInWindow = (
count: number,
startMin: number,
endMin: number
): number[] => {
const windowMin = endMin - startMin;
if (windowMin <= 0 || count <= 0) return [];
const slotCount = Math.min(count, windowMin);
const slotLength = windowMin / slotCount;
const maxJitter = Math.min(slotLength * JITTER_FRACTION, MAX_JITTER_MINUTES);
const lastMinute = endMin - 1;
const minuteForSlot = (slot: number, taken: readonly number[]): number => {
const slotCenter = startMin + (slot + 0.5) * slotLength;
const candidate = clamp(
Math.round(slotCenter + signedJitter(maxJitter)),
startMin,
lastMinute
);
return nextFreeMinute(candidate, taken, startMin, lastMinute);
};
const chosen = range(slotCount).reduce<number[]>(
(taken, slot) => [...taken, minuteForSlot(slot, taken)],
[]
);
return [...chosen].sort((a, b) => a - b);
};
Daily triggers recur on their own, so there's no top-up logic and nothing breaks if the app isn't opened for a week. The spread is the whole point. A predictable ping at exactly 3:00 every day doesn't train the habit, your brain just learns to ignore 3:00. Although I like this approach, it's not truly random. Times generated for today will repeat for next days, until the user reopens the app.
export async function scheduleNPerDayInWindow(
params: ScheduleNPerDayInWindowParams
) {
const {
identifierPrefix,
channelId,
count,
startHour,
endHour,
pickContent,
data,
} = params;
await cancelByIdOrPrefix(identifierPrefix);
const minutes = spreadMinutesOfDayInWindow(count, startHour * 60, endHour * 60);
if (minutes.length === 0) return;
try {
await Promise.all(
minutes.map((minuteOfDay, index) => {
const content = pickContent();
const { hour, minute } = splitMinuteOfDay(minuteOfDay);
return Notifications.scheduleNotificationAsync({
identifier: `${identifierPrefix}${index}`,
content: { ...content, data },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DAILY,
hour,
minute,
channelId,
},
});
})
);
} catch (err) {
console.error(err, 'scheduleNPerDayInWindow failed');
}
}
And example of a call...Let's say user wants to be notified exactly 4 times between 9am and 10pm:
await scheduleNPerDayInWindow({
identifierPrefix: 'reality-check-',
channelId: 'window',
count: 4,
startHour: 9,
endHour: 22,
pickContent: () => ({
title: 'Reality check',
body: 'Are you dreaming? Check the lights, your hands, try to speak.',
}),
data: { screen: '/scheduled', kind: 'window', label: 'Reality check' },
});
Btw, pickContent is a function call, my solution for rotating different reality checks.
Showing notifications while the app is open
If you want a notification to show while the app is in the foreground, you have to set a handler once at module load. Without it, a foreground notification is silently swallowed:
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
Testing
This is a small helper to test your notifications right away:
Notifications.scheduleNotificationAsync({
content: { title: 'Test', body: 'Did this fire?' },
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 5,
channelId: CHANNELS.logDream,
},
});
Five seconds instead of a day. Rip it out before you ship.
Updating and cancelling reminders
Scheduling is only half the job. Every reminder is user-configurable, so the moment someone changes the time, switches the weekday, or reopens the settings screen, I have to deal with what's already scheduled, otherwise I stack duplicates or keep firing a reminder they turned off.
The pattern I settled on is "make every helper idempotent". Each one cancels its own identifier (or identifier prefix) before scheduling. That cancel is a small helper:
export async function cancelByIdOrPrefix(idOrPrefix: string) {
const scheduled = await Notifications.getAllScheduledNotificationsAsync();
await Promise.all(
scheduled
.filter((n) => n.identifier.startsWith(idOrPrefix))
.map((n) => Notifications.cancelScheduledNotificationAsync(n.identifier)),
);
}
An exact identifier (log-dream, weekly-progress) cancels that one reminder. A prefix (reality-check-) wipes the whole batch the windowed scheduler created. Because the schedule helpers call this first, "the user saved new settings" is just "call the helper again". There's no way to end up with a duplicate or an orphan, and no per-id bookkeeping to maintain.
For a global "turn everything off" switch there's a sledgehammer: cancelAllScheduled(), a one-liner over Notifications.cancelAllScheduledNotificationsAsync(). Fine for a "disable all reminders" button, wrong for anything granular. Pair it with clearing the saved preferences (next section), or the cold-start reschedule will just bring everything back. When something looks off, getAllScheduledNotificationsAsync() shows you what's actually pending.
Btw, don't call your schedule functions on every mount without cancelling first. The idempotent helpers handle that for their own identifiers, but if you ever schedule raw notifications without a stable identifier you can pile them up until you blow past the OS pending-notification limit.
Persisting settings and rescheduling on launch
The OS keeps scheduled notifications across app kills and reboots, so the reminders keep firing on their own. But, OS doesn't remember the settings the user picked (reopen the screen and you have no idea what they configured), and it doesn't refresh the reality-check jitter, the times you computed once just keep repeating.
Both are fixed the same way: persist a tiny preferences object and re-derive the schedule from it on every launch. I use AsyncStorage, it's a demo, a single JSON blob is plenty.
export type ReminderPrefs = {
daily: { enabled: boolean; hour: number; minute: number };
weekly: { enabled: boolean; weekday: number; hour: number; minute: number };
window: { enabled: boolean; count: number; startHour: number; endHour: number };
};
export async function loadPrefs(): Promise<ReminderPrefs> {
const raw = await AsyncStorage.getItem('reminderPrefs');
if (!raw) return DEFAULT_PREFS;
const parsed = JSON.parse(raw) as Partial<ReminderPrefs>;
return {
daily: { ...DEFAULT_PREFS.daily, ...parsed.daily },
weekly: { ...DEFAULT_PREFS.weekly, ...parsed.weekly },
window: { ...DEFAULT_PREFS.window, ...parsed.window },
};
}
The interesting bit is rescheduleAll, which turns saved prefs back into scheduled notifications. It only schedules the enabled ones, and because every helper cancels its own identifier/prefix first, calling it repeatedly is safe and idempotent:
export async function rescheduleAll(prefs: ReminderPrefs) {
if (prefs.daily.enabled) {
await scheduleDaily({ identifier: 'log-dream', channelId: 'daily', ...prefs.daily, content: /* ... */ });
}
if (prefs.weekly.enabled) {
await scheduleWeekly({ identifier: 'weekly-progress', channelId: 'weekly', ...prefs.weekly, content: /* ... */ });
}
if (prefs.window.enabled) {
await scheduleNPerDayInWindow({ identifierPrefix: 'reality-check-', channelId: 'window', ...prefs.window, pickContent: /* ... */ });
}
}
Handling notification tap navigation
When a user taps a notification, the app should open and land on the right screen. This is something I could never get working reliably on Android with bare Firebase messaging. expo-notifications is what finally made it behave.
I put the listener in the root layout, but not the way the official docs show it. The docs lean on the useLastNotificationResponse hook. I want one place that handles both cases, a tap while the app is running and a tap that cold-starts the app from a killed state, and routes off the notification's own data payload. So the root layout registers the listener and also explicitly reads the last response on mount, guarded by a ref so the cold-start tap isn't handled twice.
function routeFromResponse(response: Notifications.NotificationResponse | null) {
const data = response?.notification.request.content.data as
| NotificationData
| undefined;
if (data?.screen) {
router.push({
pathname: data.screen,
params: data as Record<string, string | number>,
});
}
}
export default function RootLayout() {
const handledColdStart = useRef(false);
useEffect(() => {
setupNotificationChannels([/* daily, weekly, window - see above */]);
// cold start: app launched by tapping a notification, handle once
const response = Notifications.getLastNotificationResponse();
if (response && !handledColdStart.current) {
handledColdStart.current = true;
routeFromResponse(response);
}
// warm start: app already running or backgrounded
const sub = Notifications.addNotificationResponseReceivedListener(routeFromResponse);
return () => sub.remove();
}, []);
// ... <Stack> with the index and scheduled screens
}
In the demo every reminder points data.screen at one shared /scheduled screen that reads kind and label out of the params and shows which reminder fired. Rvery reminder carries its own destination in data, and routeFromResponse just pushes whatever the notification told it to. The root layout never has to know which reminder fired.
You can take a look at the demo code at GitHub.
Discussion in the ATmosphere