With expo-notifications, how do you add Taken and Snooze buttons to a medication reminder, and how does the app learn which was pressed?
answer
- register the category first
- setNotificationCategoryAsync(id, actions)
- content.categoryIdentifier
- actionIdentifier in the response
- opensAppToForeground defaults to true
basics
~10 sRegister a category with Notifications.setNotificationCategoryAsync(identifier, actions) at startup, set content.categoryIdentifier on each reminder, then read response.actionIdentifier from the notification response to see which button was pressed.
solid answer
~40 sAction buttons come from a **category**: `setNotificationCategoryAsync('medreminder', [{ identifier: 'taken', buttonTitle: 'Taken' }, { identifier: 'snooze', buttonTitle: 'Snooze 10 min' }])`, registered at startup on both platforms, then referenced by `categoryIdentifier` in each reminder's content. Avoid `:` and `-` in category identifiers; the docs warn they can break categories. When the user presses a button, the notification response carries that action's `identifier` in `actionIdentifier` (a plain tap gives `DEFAULT_ACTION_IDENTIFIER`), and a `textInput` action adds `userText`. Each action's `options.opensAppToForeground` defaults to `true`; setting it to `false` lets Taken be recorded without opening the app, but if the app is killed the response listener is then not triggered, so that path needs the background notification task. `isDestructive` and `isAuthenticationRequired` are iOS-only.
code
typescript · 34 linesimport * as Notifications from 'expo-notifications';
export async function registerMedicationActions() {
await Notifications.setNotificationCategoryAsync('medreminder', [
{ identifier: 'taken', buttonTitle: 'Taken' },
{ identifier: 'snooze', buttonTitle: 'Snooze 10 min' },
]);
}
export function listenForMedicationActions(record: (medId: string) => void) {
return Notifications.addNotificationResponseReceivedListener(async (response) => {
const medId = response.notification.request.content.data?.medId;
if (typeof medId !== 'string') return;
if (response.actionIdentifier === 'taken') {
record(medId);
} else if (response.actionIdentifier === 'snooze') {
await Notifications.scheduleNotificationAsync({
content: {
title: response.notification.request.content.title,
body: response.notification.request.content.body,
data: { medId },
categoryIdentifier: 'medreminder',
},
trigger: {
type: Notifications.SchedulableTriggerInputTypes.DATE,
date: Date.now() + 10 * 60 * 1000,
channelId: 'meds-v2',
},
});
}
await Notifications.dismissNotificationAsync(response.notification.request.identifier);
});
}go deeper
Remember the three steps: register a category with setNotificationCategoryAsync, set categoryIdentifier on the notification, read actionIdentifier from the response.
Explain the action fields and which are iOS-only, the DEFAULT_ACTION_IDENTIFIER check, text-input replies and why categories must be registered before reminders fire.
Handle the no-open path correctly: know that opensAppToForeground: false skips the listener for a killed app, route those presses to the background task, and implement Snooze as a rescheduled DATE trigger.
Decide which actions must work without opening the app, given that such paths are less reliable, and keep the adherence record correct when a press is lost.
## Categories hold the buttons In expo-notifications, action buttons are not set on a notification directly. They belong to a **notification category**, registered once and referenced by identifier: 1. **Register** the category with `Notifications.setNotificationCategoryAsync(identifier, actions, options?)`. It works on Android and iOS and resolves the created category. 2. **Reference** it from each notification with `content.categoryIdentifier`. 3. **Handle** the button press through the notification response. Register categories at app startup, before any reminder that uses them can fire. The documentation warns against using `:` or `-` in the category identifier, which can stop categories from working. ## Defining actions Each `NotificationAction` has: | Field | Meaning | |---|---| | `identifier` | String reported back as `actionIdentifier` when pressed | | `buttonTitle` | The button's label | | `textInput` | Optional; turns the button into a text reply with a `placeholder` (and `submitButtonTitle` on iOS) | | `options.opensAppToForeground` | Whether pressing it brings the app forward; defaults to `true` | | `options.isDestructive` | iOS only; shows the title in a destructive style | | `options.isAuthenticationRequired` | iOS only; requires the user to authenticate before the action runs | For a medication reminder, a sensible set is **Taken**, **Snooze 10 min**, and perhaps **Skip** marked destructive on iOS. Category-level options such as `previewPlaceholder`, `showTitle` or `customDismissAction` are iOS-only. ## Learning which button was pressed The press arrives as a **notification response**: - `response.actionIdentifier` is the action's `identifier`, or `Notifications.DEFAULT_ACTION_IDENTIFIER` for a plain tap; - `response.userText` holds the reply for a text-input action; - `response.notification.request.content.data` carries whatever the reminder stored, such as the medication id. The app reads it through `addNotificationResponseReceivedListener`, or through `getLastNotificationResponse()` for a press that launched the app. ## Buttons that do not open the app "Taken" is best recorded without opening the app, so it is tempting to set `opensAppToForeground: false`. The documented catch: **if the app is killed** (not just backgrounded), the response listener **is not triggered** for such an action. On Android a registered background notification task runs for action presses in the background and terminated states, so that is where a no-open Taken must be handled. Keeping `opensAppToForeground: true` is the simple, reliable choice when the handler must always run. ## Where the handler runs The app learns about a press in different places depending on its state and the action's options: 1. **Foreground or background, any action**: `addNotificationResponseReceivedListener` receives the response. 2. **Killed, action that opens the app**: the app launches; read the press with `getLastNotificationResponse()` at startup, since the listener may not be registered in time. 3. **Killed, action with `opensAppToForeground: false`**: the listener is not triggered; on Android the background notification task receives it. Keeping the recording logic in one function that all three paths call avoids three slightly different versions of "mark dose as taken". ## Implementing Snooze Snooze is just scheduling: 1. Read the medication id from the response's `data`. 2. Schedule a new notification with a `DATE` trigger ten minutes ahead, the same content and `categoryIdentifier`, and the medication channel. 3. Dismiss the original with `Notifications.dismissNotificationAsync(response.notification.request.identifier)` so the tray is not cluttered. ## Text replies An action with `textInput` turns the button into a reply field: `placeholder` sets the hint, and on iOS `submitButtonTitle` labels the send button. The typed text arrives as `response.userText`. For a medication app this can capture a short note, such as "took it with food" or a reason for skipping, without opening the app. As with any action, check `actionIdentifier` first, then read `userText` only for the action that defines a text input. ## Keeping categories in sync Categories are registered on the device, so a new app version that adds or renames an action must register the updated category at startup before new reminders use it. Reminders scheduled earlier keep referencing the category by identifier, so keeping the identifier stable and changing only the actions is the least disruptive path. ## Common mistakes - Setting `categoryIdentifier` on a notification before the category is registered. - Using a category id like `med-reminder`, which contains a hyphen. - Treating every response as a tap and ignoring `actionIdentifier`. - Setting `opensAppToForeground: false` and handling the press only in a listener that never runs for a killed app.
- Why might a Taken button set to opensAppToForeground: false never be recorded?expo-notifications documents that when such an action is pressed while the app is killed, the response listener is not triggered, because the app is not brought up. On Android a background notification task runs for action presses in that state, so the press must be handled there, or the action should keep the default `true`.
- How does the app tell a plain tap on the reminder from a button press?Both arrive as notification responses. A plain tap has `actionIdentifier` equal to `Notifications.DEFAULT_ACTION_IDENTIFIER`; a button press has the `identifier` given to that action when the category was registered, such as `taken` or `snooze`.
saying these in an interview costs you the question
- Action buttons are passed directly in each notification's content
- Category identifiers like med-reminder are fine
- Actions with opensAppToForeground: false still reach the listener when the app is killed
- isDestructive styles the button on Android as well as iOS
- Every notification response means the user tapped the notification body