Presenting new notifications

Open a notification as a popup, bottom sheet or screen when it arrives, pick the screens it may appear on, and show the latest unseen one on app start.

Every notification lands in the inbox. The SDKs can also hand each newly arrived notification to your app, so you can present it however you like: a popup, a bottom sheet, a full screen or a toast. The SDKs decide when a notification is new; your app decides how and where it appears.

How arrivals work

  • The first feed the client loads is the baseline: what's already there isn't news (unless you ask for it, see on app start). The cached feed shown before that never counts either.
  • After that, every unread, unarchived notification published since the last fetch is an arrival. Arrivals are detected on each feed fetch: with realtime they show up within seconds, with polling within one interval while the app is in the foreground, and otherwise on the next refetch (returning to the app or refresh()).
  • While the inbox is filtered by category or shows the archive, arrivals wait: they are detected once it shows the whole feed again.
  • Each arrival goes to one listener, oldest first. A notification that no listener takes yet waits in a queue: until a matching listener subscribes, or until the app is visible again if it arrived in the background.
  • A queued notification is dropped once it's read or archived (on any device), once it expires, when the user opens the inbox (markAllAsSeen), and on identify.
  • In the browser, a notification is presented in one tab only: a hidden tab leaves it to the one the user is looking at.

React and React Native

usePresentedNotification() holds one arrived notification at a time. Render it in any component you like; dismiss() hides it, marks it read and lets the next queued one in.

import { usePresentedNotification } from "@notificationstream/react-native";

function NotificationSheet() {
  const { notification, dismiss } = usePresentedNotification();
  return (
    <BottomSheet visible={!!notification} onClose={() => dismiss()}>
      {notification && (
        <>
          <Text>{notification.title}</Text>
          {notification.body && <Text>{notification.body}</Text>}
        </>
      )}
    </BottomSheet>
  );
}

Pass dismiss({ markAsRead: false }) to keep it unread in the inbox. For lower-level control, useNotificationArrival(listener, options) calls listener with each arrival; return false from it to leave the notification queued.

Choosing the presentation

Tell the app how to present a notification with its category or data, for example "data": { "display": "bottom_sheet" }:

const { notification, dismiss } = usePresentedNotification();
if (!notification) return null;
switch (notification.data?.display) {
  case "screen":
    return <AnnouncementScreen item={notification} onClose={() => dismiss()} />;
  case "popup":
    return <Popup item={notification} onClose={() => dismiss()} />;
  default:
    return <NotificationBottomSheet item={notification} onClose={() => dismiss()} />;
}

Choosing the screens

Mount the hook in the screens (or layouts) where notifications may appear, and use filter to say which notifications each one takes. A notification no mounted screen takes waits in the queue until the user reaches a screen that does.

// Send with "data": { "screen": "home" } to show it on the home screen only.
function HomeScreen() {
  const isFocused = useIsFocused(); // React Navigation / Expo Router
  const { notification, dismiss } = usePresentedNotification({
    filter: (item) => item.data?.screen === "home",
    enabled: isFocused,
  });
  // …
}
  • enabled matters on React Native, where screens in a stack stay mounted in the background. Pass useIsFocused() so only the visible screen presents. On the web, mount the hook in the route component or pass enabled: pathname === "/dashboard".
  • Leave filter out on a screen that may show anything. To keep notifications away from a screen (checkout, a video call), just don't mount the hook there: they wait until the user leaves.
  • When several mounted hooks match, the one that subscribed first wins.

On app start

deliverOnStart on the provider (or createNotificationClient) also presents notifications that already existed when the app started:

Value
"none" (default)Only notifications that arrive while the app runs.
"latest"Also the newest unseen notification.
"unseen"Also every unseen notification on the first page, oldest first.

"Unseen" means not read, not archived and published after the user last opened the inbox.

<NotificationProvider
  publishableKey="pk_live_…"
  user={{ id: user.id }}
  token={getNotificationToken}
  deliverOnStart="latest"
>
  <App />
  <NotificationSheet />
</NotificationProvider>

Since dismiss() marks the notification read, it isn't presented again on the next start. A user switch (identify) counts as a new start.

JavaScript

client.onNotification(listener, { filter }) is the same mechanism without React. It starts the client like subscribe and returns an unsubscribe function.

const client = createNotificationClient({ publishableKey, user, token, deliverOnStart: "latest" });

// One at a time: stop listening while a dialog is open, the rest stay queued.
function listen() {
  const off = client.onNotification((item) => {
    off();
    showDialog(item, () => {
      void client.markAsRead(item.id);
      listen();
    });
  });
}
listen();

A listener can also return false to leave a notification queued. It's offered again on the next fetch or when a listener subscribes.