Receiving and Acting on a Notification
Handle a notification in the foreground, in the background and from a cold start, then deep-link the tap and set the badge.
const sub = Notifications.addNotificationResponseReceivedListener((response) => {}); sub.remove();A notification reaches your code differently depending on the app's state. Getting this wrong is the usual reason a tap appears to do nothing.
Foreground
The app is open. Nothing is displayed unless setNotificationHandler returns shouldShowBanner: true. Separately, addNotificationReceivedListener fires with the whole notification. That is where you refresh a list or bump an unread count. It does not fire for taps.
Background and quit
The system draws the notification itself. When the user taps it, addNotificationResponseReceivedListener fires — but only if your listener is already mounted. From a cold start the tap happens before React renders anything, so read it with getLastNotificationResponseAsync() or the useLastNotificationResponse() hook instead. Both listeners return a subscription; call remove() on it in your effect cleanup.
Deep-linking the tap
Put routing information in the payload's data object, then read response.notification.request.content.data and navigate. const { notification } = response is shorthand for const notification = response.notification. Compare response.actionIdentifier with Notifications.DEFAULT_ACTION_IDENTIFIER to tell a tap on the body from a tap on an action button.
Badge
setBadgeCountAsync(n) sets the number on the app icon and 0 clears it; getBadgeCountAsync() reads it. iOS always shows it. On Android it depends on the launcher, so never treat the badge as storage — keep the real count on your server or in local storage.
Example
import { useEffect, useRef } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';
type PushData = { screen?: string; id?: string };
export default function NotificationRouter() {
const router = useRouter();
const handled = useRef<string | null>(null);
// Covers the cold-start tap: the hook returns the response that launched the app.
const lastResponse = Notifications.useLastNotificationResponse();
useEffect(() => {
if (!lastResponse) return;
const { request } = lastResponse.notification;
// The hook returns the same object every render, so handle it once.
if (handled.current === request.identifier) return;
handled.current = request.identifier;
const data = request.content.data as PushData;
if (data.screen === 'order' && data.id) {
router.push({ pathname: '/orders/[id]', params: { id: data.id } });
}
}, [lastResponse, router]);
useEffect(() => {
// Fires only while the app is open, and never for a tap.
const sub = Notifications.addNotificationReceivedListener(async () => {
const current = await Notifications.getBadgeCountAsync();
await Notifications.setBadgeCountAsync(current + 1);
});
return () => sub.remove(); // without this you get a second listener on reload
}, []);
// Screen: one line of text. Nothing visible happens until a notification arrives;
// a push received while open raises the icon badge by one, and tapping a push
// that carries { screen: 'order', id: '42' } opens the /orders/42 route.
return (
<View style={styles.screen}>
<Text>Waiting for notifications</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});When to use it
- Open the right order screen when the user taps a shipping notification.
- Refresh an inbox list when a message push arrives while the app is open.
- Clear the icon badge once the user has read the notifications.
- Handle a tap that launched the app from a fully closed state.
More examples
A reusable hook for taps, warm and cold
Mount this once near the root of the app, above your navigator, and pass a stable callback wrapped in useCallback so the effect does not re-subscribe on every render. Handling both paths in one place is what makes a tap behave the same whether the app was backgrounded or fully closed, which is the single most common gap in notification code.
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
type OnTap = (response: Notifications.NotificationResponse) => void;
export function useNotificationTaps(onTap: OnTap) {
useEffect(() => {
let mounted = true;
// Cold start: the tap happened before this component existed.
Notifications.getLastNotificationResponseAsync().then((response) => {
if (mounted && response) onTap(response);
});
// Warm: app was backgrounded, listener is already attached.
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
if (response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER) {
onTap(response); // the body was tapped, not an action button
}
});
return () => {
mounted = false;
sub.remove();
};
}, [onTap]);
}Reacting to a data-only push in the background
Only a headless notification wakes this task: one carrying just a data key, with no title or body, and with _contentAvailable set to true on iOS. Add a title and the system shows it instead and your task never runs. Treat delivery as optional, not guaranteed: the OS may skip it entirely, for instance when an Android device is in Doze mode, so this is an optimisation and never your only sync path.
import * as Notifications from 'expo-notifications';
import * as TaskManager from 'expo-task-manager';
const BACKGROUND_TASK = 'push-received';
// Must be at module scope so it is defined before the OS runs it.
TaskManager.defineTask(BACKGROUND_TASK, ({ data, error }) => {
if (error) return;
// Keep this short: sync a counter, write to storage, nothing heavy.
console.log('background payload', data);
});
export async function registerBackgroundHandler(): Promise<void> {
await Notifications.registerTaskAsync(BACKGROUND_TASK);
}Turning a payload into a route
Payloads come from your server and from whoever can call it, so treat them as untrusted input. Keeping the mapping in a pure function means you can test every payload shape in a plain unit test, and it forces you to decide what an unrecognised screen does. Returning null, and simply opening the app, is better than pushing a route that does not exist.
type PushData = { screen?: string; id?: string };
type Route = { pathname: string; params: Record<string, string> };
// Pure, so it can be unit tested without a device.
export function routeFor(data: PushData): Route | null {
if (data.screen === 'order' && data.id) {
return { pathname: '/orders/[id]', params: { id: data.id } };
}
if (data.screen === 'chat' && data.id) {
return { pathname: '/chats/[id]', params: { id: data.id } };
}
return null; // unknown payload: open the app, do not crash
}
console.log(JSON.stringify(routeFor({ screen: 'chat', id: '7' })));
// Output: {"pathname":"/chats/[id]","params":{"id":"7"}}
console.log(routeFor({ screen: 'nope' }));
// Output: null
Discussion