Interactive notifications are best for one-step commands with a clear result. Open the app when the user needs context, confirmation, or several choices. Treat the category, action identifiers, and data payload as a versioned protocol shared by the notification sender and every app lifecycle path.
Define a Small Action Protocol
Put stable identifiers in a small protocol module and register categories during startup, before scheduling a local notification or receiving a remote notification that references them. Expo warns against : and - in category identifiers.
import * as Notifications from "expo-notifications";
export const INBOX_CATEGORY = "inboxActions";
export const ARCHIVE_ACTION = "archiveItem";
export async function configureNotificationActions() {
await Notifications.setNotificationCategoryAsync(INBOX_CATEGORY, [
{
identifier: ARCHIVE_ACTION,
buttonTitle: "Archive",
options: { opensAppToForeground: false },
},
]);
}
Use opensAppToForeground: false only when the command can finish safely without mounted UI. Expo also supports destructive, authentication-required, and text-input actions, but constrained system UI is not a replacement for a full screen.
Send Minimal, Non-Secret Context
The payload should contain stable identifiers rather than an entire record. A unique command ID gives the persistence layer a way to reject duplicate delivery.
import * as Notifications from "expo-notifications";
import { INBOX_CATEGORY } from "./notification-protocol";
type InboxReminder = {
itemId: string;
commandId: string;
title: string;
};
export function scheduleInboxReminder(input: InboxReminder) {
return Notifications.scheduleNotificationAsync({
content: {
title: input.title,
body: "You can archive this without opening the app.",
categoryIdentifier: INBOX_CATEGORY,
data: {
itemId: input.itemId,
commandId: input.commandId,
},
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 60 * 30,
},
});
}
Remote notifications sent through Expo Push Service use categoryId in the server payload. Notification data can pass through third-party infrastructure, so never include credentials or private content and always validate received values.
Validate Before Mutating Durable State
Every interaction reaches the response listener, including an ordinary tap on the notification body. Check the action identifier first, parse the data defensively, and express the desired final state rather than toggling it.
import * as Notifications from "expo-notifications";
import { ARCHIVE_ACTION } from "./notification-protocol";
type ArchiveCommand = {
itemId: string;
commandId: string;
};
export interface NotificationCommandStore {
archiveOnce(command: ArchiveCommand): Promise<void>;
}
function readArchiveCommand(
response: Notifications.NotificationResponse | null,
): ArchiveCommand | null {
if (response?.actionIdentifier !== ARCHIVE_ACTION) return null;
const data = response.notification.request.content.data;
if (
typeof data?.itemId !== "string" ||
typeof data?.commandId !== "string"
) {
return null;
}
return { itemId: data.itemId, commandId: data.commandId };
}
export function createNotificationActionHandler(
store: NotificationCommandStore,
) {
return async (response: Notifications.NotificationResponse | null) => {
const command = readArchiveCommand(response);
if (!command) return;
await store.archiveOnce(command);
};
}
Implement archiveOnce with a unique constraint, upsert, or server-side idempotency key keyed by commandId. If networking is unavailable, persist a pending command locally and retry it. The handler should not depend on navigation, React context, component state, or a toast host.
Cover Live and Startup Delivery
Install one response listener near the application root and inspect the last response during startup. Clear the stored response after handing it to the same idempotent handler.
import * as Notifications from "expo-notifications";
import {
createNotificationActionHandler,
type NotificationCommandStore,
} from "./notification-response";
export function startNotificationActionHandling(
store: NotificationCommandStore,
) {
const handle = createNotificationActionHandler(store);
const subscription =
Notifications.addNotificationResponseReceivedListener(handle);
const initialResponse = Notifications.getLastNotificationResponse();
if (initialResponse) {
void handle(initialResponse).finally(() => {
Notifications.clearLastNotificationResponse();
});
}
return () => subscription.remove();
}
On iOS, register response handling early enough for launches caused by a notification interaction. On Android, use Notifications.registerTaskAsync with a module-scope expo-task-manager task when action handling must run while the app is backgrounded or terminated. Keep task work short and pass it through the same validation and idempotency boundary.
Background execution is best-effort. Device settings, Android Doze mode, throttling, and force-stop behavior can delay or prevent work. Reconcile pending commands when the app next becomes active.
Verification Matrix
Test on physical iOS and Android devices:
| Condition | Expected result |
|---|---|
| Notification body tapped | No command mutation; route according to product behavior |
| Known action, valid payload | Desired state written once |
| Unknown action or malformed payload | Ignored and recorded for diagnostics |
| Same response delivered twice | One durable result |
| App foregrounded | Live listener handles the action |
| App backgrounded | Listener or registered task handles the action |
| App terminated | Platform-specific startup path handles or later reconciles it |
| Network unavailable | Command remains durable and retryable |