Guides / React Native
React Native
The same client on a phone: useNotifications from easy-ping/react-native, a live stream through expo/fetch, and native push.
The server does not know or care that the client is a phone. Everything on this page is about
the client, and most of it is already handled by easy-ping/react-native.
What changes on a phone
- Hidden means backgrounded.
AppStatereplacesdocument.visibilityState: no polling while the app is in the background, an immediate refresh when it returns to the foreground. - One app is one user. There are no tabs, so there is no leader to elect and nothing to mirror. The client runs as a single connection.
- Identity is a bearer token, not a cookie. You add it in
fetch; the server'sgetUserIdreads it. React Native sends noOriginheader, which the CSRF check treats as same-origin. - Streaming depends on the fetch. React Native's built-in
fetchcannot stream a response body. The client detects that on the first attempt and polls instead.expo/fetchstreams, and with it the bell is as live as on the web. - Web push does not exist. Phones use the mobile push channel.
useNotifications
import { useNotifications } from "easy-ping/react-native";
const { notifications, unseenCount, unreadCount, markSeen, markAsRead, loadMore } =
useNotifications({ baseUrl: "https://app.example.com/api/notifications", fetch: authedFetch });Same result shape as the web hook, same optimistic updates, same options (limit,
pollIntervalMs, safetyNetMs, transport, …). The hook wires AppState for you; pass
appState only to inject a fake in tests. createNativeNotifyClient is the hook-free version for
other state layers.
Identity is a header
import { fetch as expoFetch } from "expo/fetch";
export const authedFetch: typeof globalThis.fetch = async (input, init) => {
const headers = new Headers(init?.headers);
headers.set("authorization", `Bearer ${await getSessionToken()}`);
return expoFetch(String(input), { ...init, headers } as never) as unknown as Response;
};On the server, session.getUserId verifies that header and returns the user id. Nothing else
differs from a browser session.
Keeping the live stream
createNativeNotifyClient tries the event stream first. With a fetch that returns a null body for
GET /events it switches to polling after that one attempt, so a plain React Native fetch costs
you nothing but the live updates. Pass fetch from expo/fetch (Expo SDK 52+) and the stream
works: changed arrives, the client refetches, and polling drops to the five-minute safety net.
client.getTransport() tells you which mode you ended up in.
Native push
import * as Notifications from "expo-notifications";
import { registerMobilePushDevice } from "easy-ping/react-native";
const { status } = await Notifications.requestPermissionsAsync();
if (status === "granted") {
const { data: token } = await Notifications.getExpoPushTokenAsync();
await registerMobilePushDevice({ baseUrl, fetch: authedFetch, token, platform: Platform.OS === "ios" ? "ios" : "android" });
}Since SDK 53, Expo Go on Android throws the moment expo-notifications is imported, which takes
the whole app down before the bell renders. Import it lazily (await import("expo-notifications")
inside the enable-push handler) and hide the button when Constants.executionEnvironment === "storeClient" on Android. Native push there needs a development build; the bell and the live
stream work in Expo Go regardless. iOS Expo Go still delivers push.
Every send with mobilePush in its channels then lands on the device. The data the plugin
sends carries notificationId and type, so a tap handler can mark that notification read. The
server side is on the mobile push page.
The example app
examples/expo in the library repo is a complete screen: bell, mark seen, enable and disable
push, tap-to-read. It sits outside the pnpm workspace on purpose, so the library's install stays
free of the native toolchain; its README says how to run it in a fresh Expo project.