Skip to main content

Notifications that open a screen

A tap on a push notification opens its screen: with Firebase Cloud Messaging, Notifee or expo-notifications, in a running app, at a launch with the screen already in place, and after the user signs in.

A push notification says "New message from Anna", and a tap on it should open the chat with Anna. The notification itself is received and shown by your notification library; the navigation opens the screen. Here you connect the two. You end with:

  • a tap that opens the chat over the Inbox tab, wherever the user is in the app;
  • a tap that launches the app with the chat already shown, back going to Inbox;
  • a tap while signed out that opens the chat after the sign-in.

What you need​

  • A notification library that already delivers notifications to your app. This guide shows three, written against these versions: @react-native-firebase/messaging 26 (Firebase Cloud Messaging), @notifee/react-native 9, and expo-notifications of Expo SDK 58. Their setup (permissions, push certificates, google-services.json) is in their own docs.
  • A notification that carries the id of the chat in its data: { "chatId": "7" }.
  • An app with tabs, Inbox and Settings, and a Chat screen that takes { chatId: string }: Tabs, Screens and props.
  • How the app starts: willMount, didMount and named roots. See Starting the app and Switching roots.

How it will work​

A tap comes at one of three moments, and each wants something different:

the app runs (front or background)
→ the library's tap listener → openChat(chatId): a push

the app isn't running: the tap launches it
→ willMount reads the launching notification → the context
→ initialRoot builds the tabs with the chat on top: no push

the user is signed out
→ the chat is kept → opened after the sign-in
  • One function, openChat, opens the chat from wherever the user is. Every tap ends in it.
  • One file, src/notifications.ts, is the only one that knows your library. It gives the rest of the app two things: the chat of the notification that launched the app, and a listener for the taps that come later.
  • The navigation's own places for each moment: willMount before the first screen, initialRoot for the first root, didMount once the app shows.

In React Navigation you would wait for the navigator to be ready and then navigate, or give linking a getInitialURL that reads the notification. Here the first root itself can be the chat, built before the splash screen goes.

1. One function that opens the screen​

A tap can come at any moment, wherever the user is. Put what it does in one function, outside the screens. navigation works anywhere, and its commands act from the screen the user sees:

src/openChat.ts
import { navigation } from '@apexnative/navigation';

export async function openChat(chatId: string) {
await navigation.dismissAllModals();
await navigation.selectTab('inbox');
await navigation.push('Chat', { props: { chatId } });
}

Each command waits for the one before: the modals close, the Inbox tab shows, then Chat comes in over it. 'inbox' is the id of the tab, given in the layout with Tab('Inbox').id('inbox'). More: Anywhere else: navigation.

The user may have that chat open already, under other screens. A key makes Chat one screen per chat, so a second tap goes back to it instead of opening a copy:

src/screens/Chat.tsx
export const ChatScreen = defineScreen({
component: Chat,
key: (props) => props.chatId,
});

More: One screen per item.

2. Read the taps from your library​

Every library has a listener for a tap and a way to read the notification that launched the app, under its own names. Put them in one file with the same two exports, whichever library it is: getLaunchChat and onChatTapped.

getInitialNotification gives the message whose tap launched the app, and onNotificationOpenedApp the taps that bring it from the background. The modular API of version 26:

src/notifications.ts
import {
getInitialNotification,
getMessaging,
onNotificationOpenedApp,
} from '@react-native-firebase/messaging';

const messaging = getMessaging();

function chatOf(data?: Record<string, unknown>) {
const chatId = data?.chatId;
return typeof chatId === 'string' ? chatId : null;
}

export async function getLaunchChat() {
const message = await getInitialNotification(messaging);
return chatOf(message?.data);
}

export function onChatTapped(open: (chatId: string) => void) {
return onNotificationOpenedApp(messaging, (message) => {
const chatId = chatOf(message.data);
if (chatId) open(chatId);
});
}

Firebase reports the launching tap to getInitialNotification only, so the two don't overlap. With the app in front, Firebase shows no banner: see A notification while the user is in the app.

Nothing changes on screen yet: the next steps call these two.

3. A tap while the app runs​

The app listens for taps once it shows its first screen: didMount runs then, once per run of the JavaScript, so the listener is added once and stays for as long as the app runs.

Navigation.ts
import { onChatTapped } from './src/notifications';
import { openChat } from './src/openChat';

export const Navigation = createNavigation({
// …
didMount: () => {
onChatTapped(openChat);
},
});

The app is in the background, a notification comes, and the user taps it. The app comes to the front, its modals close, Inbox shows, and the chat slides in. Back goes to Inbox.

4. A tap that launches the app​

When the app isn't running, the tap launches it. openChat in didMount would work: the user would see Inbox, then the push of the chat. Better: the chat is the first screen, shown as the splash screen goes, with Inbox under it to go back to.

willMount runs before the first root, while the splash screen shows. It reads the notification that launched the app and puts its chat into the app's context:

Navigation.ts
import { getLaunchChat } from './src/notifications';

export type AppContext = { launchChat: string | null };

export const Navigation = createNavigation({
// …
context: { launchChat: null } as AppContext,
willMount: async () => {
// null without a notification
const launchChat = await getLaunchChat();
navigation.setContext({ launchChat });
},
});

Then the first root comes from the context. A root of roots can take a param, so the tabs are built once, with or without a chat on top of Inbox:

Navigation.ts
const tabs = (chatId?: string) =>
Tabs(
Tab(
chatId
? Stack('Inbox', Screen('Chat', { chatId }))
: Stack('Inbox'),
)
.label('Inbox')
.id('inbox'),
Tab('Settings').label('Settings'),
);

export const Navigation = createNavigation({
// …
roots: {
signedIn: tabs(),
chat: (chatId: string) => tabs(chatId),
},
initialRoot: (context) =>
context.launchChat ? ['chat', context.launchChat] : 'signedIn',
});

The user taps a notification with the app closed. The splash screen shows, then the chat, at once and with no transition; after a splash of your own, with its transition. Back goes to Inbox. More: A root with a param.

didMount then listens for the taps that come later, as in step 3. A library that also reports the launching tap to its listener is handled in step 2: the listener skips that notification.

5. A tap while signed out​

A chat means nothing to a signed-out user: the app shows Login first. The tap is kept, and the chat opens once the user signs in. The context has the user here, as in Sign in and sign out:

src/openChat.ts
let waitingChat: string | null = null;

// The latest tap wins.
export function keepChat(chatId: string) {
waitingChat = chatId;
}

export function openChatWhenSignedIn(chatId: string) {
if (!navigation.getContext().user) return keepChat(chatId);
return openChat(chatId);
}

export async function openWaitingChat() {
const chatId = waitingChat;
waitingChat = null;
if (chatId) await openChat(chatId);
}

The tap listener of step 3 calls openChatWhenSignedIn instead of openChat, and the sign-in opens the waiting chat once the signed-in root shows:

src/session.ts
export async function signIn(email: string, password: string) {
const user = await api.signIn(email, password); // your own request
navigation.setContext((context) => ({ ...context, user }));
await navigation.switchTo('signedIn');
await openWaitingChat();
}

The user taps a notification while signed out: Login shows. After the sign-in, the tabs come in, then the chat opens over Inbox.

At a launch signed out, willMount calls keepChat with the chat of the notification instead of putting it into the context, and initialRoot picks the signed-out root: see the whole code. The whole sign-in: Sign in and sign out.

The order of a launch by a tap​

  1. 1The user taps the notification; the app launches, and the splash screen shows.
  2. 2willMount reads the notification that launched the app and sets the context.setContext({ launchChat: '7' })
  3. 3initialRoot picks the root with the chat: Inbox, and Chat over it.['chat', '7']
  4. 4The splash screen goes, and the chat shows.
  5. 5didMount starts listening for the next taps.onChatTapped(…)

A command too early​

Two places run before the app shows, and a command there doesn't do what it seems to:

  • In willMount. A command waits for the first root, and the first root waits for willMount. Awaited there, it never resolves: the start stops, and the splash screen stays. Not awaited, it runs after the first root, as a push the user sees. That is why the launch chat goes through the context instead.
  • In a background handler. Firebase's setBackgroundMessageHandler and Notifee's onBackgroundEvent are registered in index.js and can run with no screen at all, while the app isn't launched. A command there rejects: [navigation] A command before the app started: register it with AppRegistry.registerComponent. Keep them to data work, such as updating a badge count, and let the tap listener open the screen.

A notification while the user is in the app​

With the app in front, Firebase shows no system banner, and its onMessage gets the message instead. An overlay can show your own: a screen over everything that doesn't move the user, and that opens the chat when tapped.

src/screens/MessageBanner.tsx
import { Pressable, Text } from 'react-native';
import { defineScreen, useNavigation } from '@apexnative/navigation';
import { openChat } from '../openChat';

type Props = { chatId: string; text: string };

function MessageBanner({ chatId, text }: Props) {
const navigation = useNavigation();
const open = async () => {
await navigation.dismiss();
await openChat(chatId);
};
return (
<Pressable onPress={open}>
<Text>{text}</Text>
</Pressable>
);
}

export const MessageBannerScreen = defineScreen({
component: MessageBanner,
});

The banner is listed in screens as MessageBanner, like any screen. With Firebase, src/notifications.ts gets one more export, and didMount calls it next to onChatTapped:

src/notifications.ts
import { navigation } from '@apexnative/navigation';
import { onMessage } from '@react-native-firebase/messaging';
// …

// A message while the app is in front: a banner of your own.
export function showMessagesInFront() {
return onMessage(messaging, (message) => {
const chatId = chatOf(message.data);
if (!chatId) return;
navigation.showOverlay('MessageBanner', {
props: { chatId, text: message.notification?.body ?? '' },
});
});
}

The user is in Settings when a message comes: the banner shows over it, and the user stays where they are. A tap on it closes the banner and opens the chat over Inbox.

push from an overlay acts from the screen the user sees, so the banner needs nothing else; its own close and where the touches go: Overlays.

Notifee and expo-notifications can show the system banner with the app in front too: Notifee with foregroundPresentationOptions on iOS, expo-notifications with setNotificationHandler. A tap on that banner comes to the tap listener of step 3.

A notification can carry a link, such as myapp://chat/7, that the app opens with linking too. Keep one function per target, as openChat here, and call it from both places: the route of the link and the tap listener.

linking: {
routes: {
'chat/:chatId': ({ chatId }) => openChat(chatId),
},
},

The routes of linking run for the links the system gives the app. A tap that your library reports is not one of them, so it calls the function itself. More: Deep links.

When Android brings the app back​

Android Android can destroy the app's activity in the background and create a new one when the user taps a notification, while the JavaScript keeps running. willMount and didMount don't run again: the last root comes back, and getLaunchChat isn't read. The listener of step 3 still runs, so when your library reports the tap to it, the chat opens over the root that came back. More: When the app launches again.

A reload of the JavaScript in development runs willMount again. Libraries differ in whether they give the launching notification a second time then; test the launch by a tap with the app quit, not with a reload.

Testing a tap​

A tap takes a different way at each moment, so try each one with a real notification, from your server or your provider's console:

  • the app in front, in the background, and quit;
  • signed in, and signed out;
  • the chat already open under other screens: the tap goes back to it.

openChat and openChatWhenSignedIn are functions of their own, so Jest checks the commands they make without a device: see Code outside the screens.

The whole code​

src/notifications.ts is the file of your library, from step 2.

src/openChat.ts
import { navigation } from '@apexnative/navigation';

export async function openChat(chatId: string) {
await navigation.dismissAllModals();
await navigation.selectTab('inbox');
await navigation.push('Chat', { props: { chatId } });
}

let waitingChat: string | null = null;

// Signed out, the chat waits; the latest tap wins.
export function keepChat(chatId: string) {
waitingChat = chatId;
}

export function openChatWhenSignedIn(chatId: string) {
if (!navigation.getContext().user) return keepChat(chatId);
return openChat(chatId);
}

export async function openWaitingChat() {
const chatId = waitingChat;
waitingChat = null;
if (chatId) await openChat(chatId);
}
Navigation.ts
import {
createNavigation,
navigation,
Screen,
Stack,
Tab,
Tabs,
} from '@apexnative/navigation';
import { getLaunchChat, onChatTapped } from './src/notifications';
import { keepChat, openChatWhenSignedIn } from './src/openChat';
import { loadSession, type User } from './src/session';
import { ChatScreen } from './src/screens/Chat';
import { InboxScreen } from './src/screens/Inbox';
import { LoginScreen } from './src/screens/Login';
import { SettingsScreen } from './src/screens/Settings';

export type AppContext = {
user: User | null;
launchChat: string | null;
};

const tabs = (chatId?: string) =>
Tabs(
Tab(
chatId
? Stack('Inbox', Screen('Chat', { chatId }))
: Stack('Inbox'),
)
.label('Inbox')
.id('inbox'),
Tab('Settings').label('Settings'),
);

export const Navigation = createNavigation({
screens: {
Login: LoginScreen,
Inbox: InboxScreen,
Chat: ChatScreen,
Settings: SettingsScreen,
},
roots: {
signedOut: Stack('Login'),
signedIn: tabs(),
chat: (chatId: string) => tabs(chatId),
},
context: { user: null, launchChat: null } as AppContext,
willMount: async () => {
const user = await loadSession(); // your own saved session
const chatId = await getLaunchChat();
// Signed out, the chat waits for the sign-in.
if (chatId && !user) keepChat(chatId);
navigation.setContext({
user,
launchChat: user ? chatId : null,
});
},
initialRoot: (context) => {
if (!context.user) return 'signedOut';
return context.launchChat
? ['chat', context.launchChat]
: 'signedIn';
},
didMount: () => {
onChatTapped(openChatWhenSignedIn);
},
});
src/session.ts
import { navigation } from '@apexnative/navigation';
import { openWaitingChat } from './openChat';

export type User = { id: string; name: string };

export async function signIn(email: string, password: string) {
const user = await api.signIn(email, password); // your own request
navigation.setContext((context) => ({ ...context, user }));
await navigation.switchTo('signedIn');
await openWaitingChat();
}

loadSession and the sign-out live in src/session.ts too, as in Sign in and sign out.

What's next​