skip to content

In a React Native chat app, why can a notification tap that launches a killed app be missed, and how do expo-notifications and React Native Firebase recover it?

level: seniorimportance: should knowfreq 40%

answer

  1. the tap happens before JS listens
  2. read it at startup, not just listen
  3. getLastNotificationResponse(), now synchronous
  4. getInitialNotification vs onNotificationOpenedApp
  5. clear it once handled

basics

~20 s

The tap that launches a killed app happens before JavaScript has registered any listener, so a listener alone can miss it. Read it at startup: getLastNotificationResponse() or useLastNotificationResponse() in expo-notifications, getInitialNotification() in React Native Firebase.

solid answer

~40 s

A tap on a notification for a killed app starts the native app first; the JavaScript bundle loads later, so the tap event can fire before `addNotificationResponseReceivedListener` exists. On Android, Expo's own table shows that a terminated-state tap does not trigger that listener at all. The fix is to **read the launching response at startup** as well as listening: expo-notifications' synchronous `getLastNotificationResponse()` (the `…Async` variant is deprecated) or the `useLastNotificationResponse()` hook, which returns `undefined` until it knows, then `null` or the response. React Native Firebase splits the same job into `getInitialNotification()` for a quit-state launch and `onNotificationOpenedApp()` for a background resume. Check `actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER` to tell a plain tap from an action button, and call `clearLastNotificationResponse()` after acting so a remount does not open the conversation again.

code

tsx · 15 lines
tsx
import * as Notifications from 'expo-notifications';
import { useEffect } from 'react';

export function useOpenChatFromNotification(openConversation: (id: string) => void) {
  const response = Notifications.useLastNotificationResponse();

  useEffect(() => {
    if (response === undefined || response === null) return;
    if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;

    const id = response.notification.request.content.data?.conversationId;
    if (typeof id === 'string') openConversation(id);
    Notifications.clearLastNotificationResponse();
  }, [response, openConversation]);
}

go deeper

for a junior

Remember that a tap which launches a killed app must be read at startup, with getLastNotificationResponse in Expo or getInitialNotification in React Native Firebase.

for a middle

Explain why listeners miss cold-start taps, the undefined, null and response states of the hook, and the DEFAULT_ACTION_IDENTIFIER check.

for a senior

Diagnose the works-from-background, fails-from-killed bug, test Android's terminated case, clear the response after use and hand off to navigation only when it is ready.

for a principal

Own one notification-open pipeline that every push type reuses, so cold start, resume and action buttons are handled once and tested on both platforms.

## Why the tap gets lost When the user taps a chat notification while the app is **killed**, the sequence is: 1. The OS launches the native app and records that it was opened from a notification. 2. React Native starts, loads the JavaScript bundle and mounts the root component. 3. Your code registers listeners, typically in effects. The tap happened at step 1, while listeners appear at step 3. Any design that relies on a **listener alone** can miss it. Expo documents the per-platform detail: | App state at tap | iOS triggers | Android triggers | |---|---|---| | Foreground | `NotificationResponseReceivedListener` | `NotificationResponseReceivedListener` | | Background | `NotificationResponseReceivedListener` | the listener and the background task | | Terminated | `NotificationResponseReceivedListener` | the background task only | On Android a terminated-state tap never reaches the listener, and on iOS it reaches it only if the listener is registered early enough; Expo recommends registering it at module top level and also **checking the last response during startup**. ## expo-notifications: read, then listen - `Notifications.getLastNotificationResponse()` returns the most recent response synchronously: `null` if none, or a `NotificationResponse` with `notification`, `actionIdentifier` and, for text-input actions, `userText`. It includes a response recorded natively at launch. `getLastNotificationResponseAsync()` still exists but is **deprecated** in favour of the synchronous call. - `Notifications.useLastNotificationResponse()` wraps both steps: it reads the last response in a layout effect and subscribes to new ones. It returns **`undefined` until it knows**, then `null` or the response. Code must not treat `undefined` as "no tap" and route to the home screen. - `Notifications.clearLastNotificationResponse()` clears the stored response and resets the hook's value to `null`. Call it after handling, so a remount or a second consumer does not act on the same tap twice. Its `…Async` variant is deprecated the same way. Check `response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER` before treating the response as "open this conversation"; action buttons such as "Mark as read" carry their own identifiers. ## React Native Firebase: two APIs for two states `@react-native-firebase/messaging` separates the cases explicitly: - `getInitialNotification(messaging)` resolves the `RemoteMessage` whose notification opened the app **from a quit state**, or `null`; - `onNotificationOpenedApp(messaging, listener)` fires when a notification opens the app from the **background**. Both are needed; using only the listener reproduces the cold-start bug. ## Getting the conversation to open Recovering the response is this leaf's job; turning it into a screen belongs to navigation. The handoff has two rules: 1. Extract the conversation id from `notification.request.content.data` (Expo) or `remoteMessage.data` (React Native Firebase). 2. Hand it to the navigation layer only once navigation is ready, otherwise the navigate call is lost in the same way. ## Where to put the startup read The read must happen once, early, and in one place: - In an Expo Router app, the root layout is the natural home for the hook, because it mounts first and stays mounted. - In a React Native Firebase app, call `getInitialNotification` once during startup, next to the code that registers `onNotificationOpenedApp`, and keep the result until navigation can use it. - Avoid reading in individual screens: two consumers of the same response race each other, and the loser either reopens the conversation or clears the response before the winner sees it. ## Diagnosing the bug in production The signature: taps work when the app is backgrounded, but from a killed app they open the home screen. Checks: - Is there a startup read (`getLastNotificationResponse`, the hook, or `getInitialNotification`), or only a listener? - Does the code act on `undefined` from the hook? - Is the response cleared after use, or does a later remount reopen an old conversation? - Does the test cover Android's terminated case, where the listener never fires? ## Common mistakes - Relying on `addNotificationResponseReceivedListener` or `onNotificationOpenedApp` alone. - Treating the hook's `undefined` as `null`. - Never clearing the response, so the app keeps reopening the same chat. - Handling every response as a tap, including action-button responses.

  • Why does useLastNotificationResponse() return undefined before returning null or a response?
    The hook starts before it has read the stored response, so `undefined` means "not known yet". `null` means no response exists. Treating `undefined` as `null` makes a cold-start tap look like a normal launch: the app routes to the home screen and then ignores the response that arrives a moment later.
  • In React Native Firebase, why are both getInitialNotification() and onNotificationOpenedApp() needed?
    They cover different states. `getInitialNotification()` resolves the notification that launched the app from a quit state, which no listener could observe. `onNotificationOpenedApp()` fires when a tap brings a backgrounded app to the foreground. A chat app needs both to open the right conversation from either state.

saying these in an interview costs you the question

  • addNotificationResponseReceivedListener alone catches taps that launch a killed app
  • getLastNotificationResponseAsync() is the current recommended API
  • undefined from useLastNotificationResponse means no notification was tapped
  • onNotificationOpenedApp also covers launches from a quit state
  • Once handled, the last notification response clears itself