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.

Syntaxconst 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

Example · tsx
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.

Example · tsx
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.

Example · tsx
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.

Example · tsx
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

  • Be the first to comment on this lesson.