Skip to content

React Native Notification Actions

A React Native guide for coding agents. Also covers react native, expo, notification categories, interactive notifications, push notification actions, background notification response.

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:

ConditionExpected result
Notification body tappedNo command mutation; route according to product behavior
Known action, valid payloadDesired state written once
Unknown action or malformed payloadIgnored and recorded for diagnostics
Same response delivered twiceOne durable result
App foregroundedLive listener handles the action
App backgroundedListener or registered task handles the action
App terminatedPlatform-specific startup path handles or later reconciles it
Network unavailableCommand remains durable and retryable

References

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "react-native-notification-actions" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems