{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreihd5snjjhf3xwnvhozgf4ri7lpwv7ont5gtxy7piaoxr2c3eexzlu",
"uri": "at://did:plc:nueu5rkumgo3omtdzftnx2ff/app.bsky.feed.post/3mm7uw3hmvbp2"
},
"description": "I added local push notifications to my dream journal app to get my users build a habit and use the app more often.\n\nNo Firebase messaging needed, just pure local push notifications scheduled on the device.\n\n\n\n\n\nI included 3 types of reminders in my app:\n\n * Log a dream - Fires at a time the user picks. Nudges them to write down their dream before they forget.\n * 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 ",
"path": "/scheduling-local-notifications-with-expo-notifications-to-increase-engagement-on-dreamiary/",
"publishedAt": "2026-05-19T17:07:48.000Z",
"site": "https://www.amarjanica.com",
"tags": [
"dream journal app",
"o Firebase messaging needed",
"that's another",
"the official doc",
"on Android 14+ this permission is denied by default",
"demo code at GitHub",
"@react-native-community"
],
"textContent": "I added local push notifications to my dream journal app to get my users build a habit and use the app more often.\n\nNo Firebase messaging needed, just pure local push notifications scheduled on the device.\n\nI included 3 types of reminders in my app:\n\n * Log a dream - Fires at a time the user picks. Nudges them to write down their dream before they forget.\n * 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.\n * Weekly progress - Fires on a chosen day of the week and hour. Pulls users back in to review their progress.\n\nPush notifications screen\n\nAlthough 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.\n\n## Prerequisites\n\nI'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.\n\nInstall dependencies:\n\n\n npx expo install expo-notifications @react-native-community/datetimepicker\n\nDatetimepicker is needed for letting the user pick the exact hour and minute a reminder fires.\n\n## Expo App config\n\nAdd this to `plugins` in app.json:\n\n\n '@react-native-community/datetimepicker',\n [\n 'expo-notifications',\n {\n sounds: [],\n },\n ],\n\nYou might want to tweak the configuration of your plugin, for full options consult the official doc.\n\nOn 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:\n\n\n android: {\n permissions: ['android.permission.SCHEDULE_EXACT_ALARM'],\n },\n\n\nOne 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.\n\nA journal app declaring it risks rejection or removal at review.\n\nIf you're using proguard, then include expo notifications in the proguard exception rule:\n\n\n -keep class expo.modules.notifications.** { *; }\n\n## Asking for Permission\n\nNotifications won't be shown before the user allows them. On iOS you won't even see the Notifications in the app system settings.\n\nThe permission helpers all live in `src/notifications/permissions.ts`:\n\n\n export type PermissionResult = { granted: boolean; canAskAgain: boolean };\n\n export async function getPermissionStatus(): Promise<PermissionResult> {\n const { status, canAskAgain } = await Notifications.getPermissionsAsync();\n const granted = status === Notifications.PermissionStatus.GRANTED;\n return { granted, canAskAgain };\n }\n\n export async function ensurePermissions(): Promise<PermissionResult> {\n let { status, canAskAgain } = await Notifications.getPermissionsAsync();\n if (status !== Notifications.PermissionStatus.GRANTED && canAskAgain) {\n ({ status, canAskAgain } = await Notifications.requestPermissionsAsync());\n }\n const granted = status === Notifications.PermissionStatus.GRANTED;\n return { granted, canAskAgain };\n }\n\n export async function openNotificationSettings(): Promise<void> {\n try {\n await Linking.openSettings();\n } catch (err) {\n throw new Error('Could not open settings', { cause: err });\n }\n }\n\n`ensurePermissions` is the one the UI calls when the user taps \"Enable notifications\".\n\nThe 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()`.\n\nSince 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.\n\n\n const appState = useRef<AppStateStatus>(AppState.currentState);\n\n useEffect(() => {\n let active = true;\n\n function refreshStatus() {\n getPermissionStatus()\n .then(({ granted }) => {\n if (!active) return;\n // update your state here...\n })\n .catch(() => {\n /* keep last known status if the check fails */\n });\n }\n\n refreshStatus();\n\n const subscription = AppState.addEventListener('change', (nextAppState) => {\n const wasBackgrounded = ['inactive', 'background'].includes(\n appState.current,\n );\n if (wasBackgrounded && nextAppState === 'active') {\n refreshStatus();\n }\n appState.current = nextAppState;\n });\n\n return () => {\n active = false;\n subscription.remove();\n };\n }, []);\n\n## Building Multiple Channels\n\nOn 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.\n\nThe setup helper is generic, it just takes a list of channel configs:\n\n\n export async function setupNotificationChannels(\n channels: { id: string; name: string; importance?: Notifications.AndroidImportance }[],\n ) {\n if (Platform.OS !== 'android') return; // no-op on iOS\n await Promise.all(\n channels.map((c) =>\n Notifications.setNotificationChannelAsync(c.id, {\n name: c.name,\n importance: c.importance ?? Notifications.AndroidImportance.DEFAULT,\n }),\n ),\n );\n }\n\nI create one channel per reminder type, so muting a channel mutes exactly that reminder. The call lives in the root layout:\n\n\n setupNotificationChannels([\n { id: 'daily', name: 'Daily reminders', importance: Notifications.AndroidImportance.HIGH },\n { id: 'weekly', name: 'Weekly reminders', importance: Notifications.AndroidImportance.HIGH },\n { id: 'window', name: 'Windowed reminders', importance: Notifications.AndroidImportance.HIGH },\n ]);\n\n`daily` is the \"Log a dream\" channel, `weekly` is \"Weekly progress\", `window` is \"Reality checks\".\n\nIt'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.\n\nAnd then you attach a notification to its channel through a `channelId` on the trigger.\n\n## Scheduling the reminders\n\nEach 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.\n\n### Daily Trigger - log a dream\n\n\n**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:\n\n\n export async function scheduleDaily(params: {\n identifier: string;\n channelId?: string;\n hour: number;\n minute: number;\n content: { title: string; body: string };\n data?: Record<string, unknown>;\n }) {\n await cancel(params.identifier); // replace, never stack\n await Notifications.scheduleNotificationAsync({\n identifier: params.identifier,\n content: { ...params.content, data: params.data },\n trigger: {\n type: Notifications.SchedulableTriggerInputTypes.DAILY,\n hour: params.hour,\n minute: params.minute,\n channelId: params.channelId,\n },\n });\n }\n\nThe dream-journal screen calls it like this:\n\n\n await scheduleDaily({\n identifier: 'log-dream',\n channelId: 'daily',\n hour,\n minute,\n content: { title: 'Log your dream', body: 'Write it down before it fades.' },\n data: { screen: '/scheduled', kind: 'daily', label: 'Log a dream' },\n });\n\nBecause 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.\n\n## Weekly trigger - weekly progress\n\nIt'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:**\n\n\n trigger: {\n type: Notifications.SchedulableTriggerInputTypes.WEEKLY,\n weekday: params.weekday, // 1 = Sunday ... 7 = Saturday\n hour: params.hour,\n minute: params.minute,\n channelId: params.channelId,\n }\n\nExample of a call:\n\n\n await scheduleWeekly({\n identifier: 'weekly-progress',\n channelId: 'weekly',\n weekday, // from the day picker, 1-7\n hour,\n minute,\n content: { title: 'Your week in dreams', body: 'See how your recall has been trending.' },\n data: { screen: '/scheduled', kind: 'weekly', label: 'Weekly progress' },\n });\n\n### Reality Checks - I like this one\n\nReality 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.\n\nI 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:\n\n\n export const spreadMinutesOfDayInWindow = (\n count: number,\n startMin: number,\n endMin: number\n ): number[] => {\n const windowMin = endMin - startMin;\n if (windowMin <= 0 || count <= 0) return [];\n\n const slotCount = Math.min(count, windowMin);\n const slotLength = windowMin / slotCount;\n const maxJitter = Math.min(slotLength * JITTER_FRACTION, MAX_JITTER_MINUTES);\n const lastMinute = endMin - 1;\n\n const minuteForSlot = (slot: number, taken: readonly number[]): number => {\n const slotCenter = startMin + (slot + 0.5) * slotLength;\n const candidate = clamp(\n Math.round(slotCenter + signedJitter(maxJitter)),\n startMin,\n lastMinute\n );\n return nextFreeMinute(candidate, taken, startMin, lastMinute);\n };\n\n const chosen = range(slotCount).reduce<number[]>(\n (taken, slot) => [...taken, minuteForSlot(slot, taken)],\n []\n );\n\n return [...chosen].sort((a, b) => a - b);\n };\n\nDaily 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.\n\n\n export async function scheduleNPerDayInWindow(\n params: ScheduleNPerDayInWindowParams\n ) {\n const {\n identifierPrefix,\n channelId,\n count,\n startHour,\n endHour,\n pickContent,\n data,\n } = params;\n\n await cancelByIdOrPrefix(identifierPrefix);\n\n const minutes = spreadMinutesOfDayInWindow(count, startHour * 60, endHour * 60);\n if (minutes.length === 0) return;\n\n try {\n await Promise.all(\n minutes.map((minuteOfDay, index) => {\n const content = pickContent();\n const { hour, minute } = splitMinuteOfDay(minuteOfDay);\n return Notifications.scheduleNotificationAsync({\n identifier: `${identifierPrefix}${index}`,\n content: { ...content, data },\n trigger: {\n type: Notifications.SchedulableTriggerInputTypes.DAILY,\n hour,\n minute,\n channelId,\n },\n });\n })\n );\n } catch (err) {\n console.error(err, 'scheduleNPerDayInWindow failed');\n }\n }\n\nAnd example of a call...Let's say user wants to be notified exactly 4 times between 9am and 10pm:\n\n\n await scheduleNPerDayInWindow({\n identifierPrefix: 'reality-check-',\n channelId: 'window',\n count: 4,\n startHour: 9,\n endHour: 22,\n pickContent: () => ({\n title: 'Reality check',\n body: 'Are you dreaming? Check the lights, your hands, try to speak.',\n }),\n data: { screen: '/scheduled', kind: 'window', label: 'Reality check' },\n });\n\nBtw, `pickContent` is a function call, my solution for rotating different reality checks.\n\n## Showing notifications while the app is open\n\nIf 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:\n\n\n Notifications.setNotificationHandler({\n handleNotification: async () => ({\n shouldShowBanner: true,\n shouldShowList: true,\n shouldPlaySound: true,\n shouldSetBadge: false,\n }),\n });\n\n### Testing\n\nThis is a small helper to test your notifications right away:\n\n\n Notifications.scheduleNotificationAsync({\n content: { title: 'Test', body: 'Did this fire?' },\n trigger: {\n type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,\n seconds: 5,\n channelId: CHANNELS.logDream,\n },\n });\n\n\nFive seconds instead of a day. Rip it out before you ship.\n\n## Updating and cancelling reminders\n\nScheduling 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.\n\nThe 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:\n\n\n export async function cancelByIdOrPrefix(idOrPrefix: string) {\n const scheduled = await Notifications.getAllScheduledNotificationsAsync();\n await Promise.all(\n scheduled\n .filter((n) => n.identifier.startsWith(idOrPrefix))\n .map((n) => Notifications.cancelScheduledNotificationAsync(n.identifier)),\n );\n }\n\nAn 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.\n\nFor 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.\n\nBtw, 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.\n\n## Persisting settings and rescheduling on launch\n\nThe 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.\n\nBoth 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.\n\n\n export type ReminderPrefs = {\n daily: { enabled: boolean; hour: number; minute: number };\n weekly: { enabled: boolean; weekday: number; hour: number; minute: number };\n window: { enabled: boolean; count: number; startHour: number; endHour: number };\n };\n\n export async function loadPrefs(): Promise<ReminderPrefs> {\n const raw = await AsyncStorage.getItem('reminderPrefs');\n if (!raw) return DEFAULT_PREFS;\n const parsed = JSON.parse(raw) as Partial<ReminderPrefs>;\n return {\n daily: { ...DEFAULT_PREFS.daily, ...parsed.daily },\n weekly: { ...DEFAULT_PREFS.weekly, ...parsed.weekly },\n window: { ...DEFAULT_PREFS.window, ...parsed.window },\n };\n }\n\nThe 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:\n\n\n export async function rescheduleAll(prefs: ReminderPrefs) {\n if (prefs.daily.enabled) {\n await scheduleDaily({ identifier: 'log-dream', channelId: 'daily', ...prefs.daily, content: /* ... */ });\n }\n if (prefs.weekly.enabled) {\n await scheduleWeekly({ identifier: 'weekly-progress', channelId: 'weekly', ...prefs.weekly, content: /* ... */ });\n }\n if (prefs.window.enabled) {\n await scheduleNPerDayInWindow({ identifierPrefix: 'reality-check-', channelId: 'window', ...prefs.window, pickContent: /* ... */ });\n }\n }\n\n## Handling notification tap navigation\n\nWhen 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.\n\nI 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.\n\n\n function routeFromResponse(response: Notifications.NotificationResponse | null) {\n const data = response?.notification.request.content.data as\n | NotificationData\n | undefined;\n if (data?.screen) {\n router.push({\n pathname: data.screen,\n params: data as Record<string, string | number>,\n });\n }\n }\n\n export default function RootLayout() {\n const handledColdStart = useRef(false);\n\n useEffect(() => {\n setupNotificationChannels([/* daily, weekly, window - see above */]);\n\n // cold start: app launched by tapping a notification, handle once\n const response = Notifications.getLastNotificationResponse();\n if (response && !handledColdStart.current) {\n handledColdStart.current = true;\n routeFromResponse(response);\n }\n\n // warm start: app already running or backgrounded\n const sub = Notifications.addNotificationResponseReceivedListener(routeFromResponse);\n return () => sub.remove();\n }, []);\n\n // ... <Stack> with the index and scheduled screens\n }\n\nIn 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.\n\n* * *\n\nYou can take a look at the demo code at GitHub.",
"title": "Scheduling Local Notifications with expo-notifications to increase engagement on Dreamiary",
"updatedAt": "2026-05-19T17:07:49.132Z"
}