DEV Community

hiro@
hiro@

Posted on • Originally published at qiita.com

Handling Notification Taps in expo-notifications: Launch vs. Runtime

This article is an English translation of the original Japanese article.

When a user taps a push notification, the entry point differs depending on whether the app is running. If you only use one listener with Expo Router to navigate to the notification target screen, you may miss launches from the terminated state.

In my app, I handle both cases with two separate mechanisms:

  • getLastNotificationResponseAsync(): when the app launches from a notification tap
  • addNotificationResponseReceivedListener(): when a notification is tapped while the app is running

Structuring Notification Data

The notifications sent from the server include the destination path in data.url.

{
  title: "Attendance Deadline Reminder",
  body: "Please confirm your attendance for tomorrow's practice",
  data: {
    url: "/organizations/org_123/schedules/schedule_456"
  }
}
Enter fullscreen mode Exit fullscreen mode

Rather than parsing the displayed title or body to determine the destination, I pass a path the app can handle as separate data.

Handling Launch from Notification

When the app is in a terminated state, I check for the last notification response after launch.

void Notifications.getLastNotificationResponseAsync().then((response) => {
  if (response) {
    handleNotificationTap(response.notification);
  }
});
Enter fullscreen mode Exit fullscreen mode

This process is asynchronous. If you call router.push before verifying authentication or before the Navigator is ready, navigation can conflict. In my app, I run this after the login check completes.

useRegisterPushToken(authChecked);
Enter fullscreen mode Exit fullscreen mode

Handling Notification Taps While Running

When a notification is tapped from either the background or foreground, the listener receives it.

const subscription =
  Notifications.addNotificationResponseReceivedListener((response) => {
    handleNotificationTap(response.notification);
  });

return () => subscription.remove();
Enter fullscreen mode Exit fullscreen mode

To avoid duplicate listeners from screen remounts or Fast Refresh, I call remove() in the cleanup.

Unifying Both Entry Points

Although the entry points differ, notification data validation and navigation are shared.

function handleNotificationTap(notification: Notifications.Notification) {
  const data = notification.request.content.data as
    | { url?: string; linkUrl?: string }
    | null;

  const raw = data?.url ?? data?.linkUrl;
  if (!raw) return;

  const path = raw.startsWith("/")
    ? raw
    : (raw.match(/^https?:\/\/[^/]+(\/.+)$/)?.[1] ?? null);

  if (!path) return;
  router.push(path as never);
}
Enter fullscreen mode Exit fullscreen mode

For backward compatibility with previously sent notifications, I accept both url and linkUrl. For new implementations, using a single key is simpler.

Also, passing externally provided URLs directly to router.push is less safe. I define allowed hosts and path formats, converting them to app-internal routes before passing.

Not Navigating on Received Notification

addNotificationReceivedListener and addNotificationResponseReceivedListener serve different purposes.

Notifications.addNotificationReceivedListener((notification) => {
  // When notification is received
});

Notifications.addNotificationResponseReceivedListener((response) => {
  // When user taps the notification
});
Enter fullscreen mode Exit fullscreen mode

Switching screens immediately when a notification arrives can disrupt the user's current screen, such as an input form. In my app, screen navigation happens only with the response after a tap.

Unified Hook

Simplifying the actual structure, it looks like this:

export function useNotificationNavigation(enabled: boolean) {
  useEffect(() => {
    if (!enabled) return;

    let active = true;

    void Notifications.getLastNotificationResponseAsync().then((response) => {
      if (active && response) {
        handleNotificationTap(response.notification);
      }
    });

    const subscription =
      Notifications.addNotificationResponseReceivedListener((response) => {
        handleNotificationTap(response.notification);
      });

    return () => {
      active = false;
      subscription.remove();
    };
  }, [enabled]);
}
Enter fullscreen mode Exit fullscreen mode

Rather than treating launches from the terminated state and taps while running as the same thing, separating the entry points and then passing them to a common navigation function made the structure easier to maintain.

References

Top comments (0)