skip to content

In an Expo app, how do you use a PermissionResponse's status and canAskAgain to handle a user who permanently denied location access?

level: middleimportance: must knowfreq 55%

answer

  1. three states, not two
  2. granted is a convenience flag
  3. canAskAgain false means no prompt
  4. send the user to Settings
  5. re-read the state on return

basics

~20 s

When status is 'denied' and canAskAgain is false, the system will not show the prompt again, so explain why location helps, offer Linking.openSettings(), and re-read the permission with the get function when the user comes back.

solid answer

~40 s

An Expo `PermissionResponse` carries `status` (`'granted'`, `'denied'` or `'undetermined'`), a `granted` boolean and `canAskAgain`. I branch on three cases. Granted: show the feature. Undetermined, or denied with `canAskAgain: true`: show a short explanation and a button that calls the request function. Denied with `canAskAgain: false`: the operating system will not prompt again, and calling request just resolves as denied, so I explain what the user gains and offer a button that calls `Linking.openSettings()` from `react-native`. When the user comes back I call the get function again, because nothing notifies the app of a change made in Settings. On iOS one denial sets `canAskAgain` to false; on Android Expo derives it after a denial from whether the system would still show the rationale.

code

tsx · 27 lines
tsx
import * as Location from 'expo-location';
import type { ReactNode } from 'react';
import { Button, Linking, Text, View } from 'react-native';

export function CafesLocationGate({ children }: { children: ReactNode }) {
  const [permission, requestPermission, getPermission] = Location.useForegroundPermissions();

  if (!permission) return null; // first render: status not read yet
  if (permission.granted) return <>{children}</>;

  if (permission.canAskAgain) {
    return (
      <View>
        <Text>Location is used only to list cafes near you.</Text>
        <Button title="Allow location" onPress={requestPermission} />
      </View>
    );
  }

  return (
    <View>
      <Text>Location is off for this app. Turn it on in Settings to see nearby cafes.</Text>
      <Button title="Open Settings" onPress={() => Linking.openSettings()} />
      <Button title="I've turned it on" onPress={getPermission} />
    </View>
  );
}

go deeper

for a junior

Recall the three status values and that canAskAgain false means the system prompt will not appear again.

for a middle

Explain the three-way branch, why request resolves silently once canAskAgain is false, and how Linking.openSettings plus a re-read recovers the feature.

for a senior

Show how iOS and Android reach canAskAgain false differently and design the screen so a permanent denial degrades gracefully instead of dead-ending.

for a principal

Treat denial recovery as a product decision: where the Settings path appears, how often it is offered, and what the feature does without the capability.

## The fields that matter Every Expo SDK permission function resolves to a **`PermissionResponse`**, defined in `expo-modules-core`: - **`status`** is a `PermissionStatus` enum: `'granted'`, `'denied'` or `'undetermined'` (never asked). - **`granted`** is a boolean that is true only when `status` is `'granted'`. It exists so the common check reads cleanly. - **`canAskAgain`** says whether a request can still show the **system prompt**. When it is false, the source comment spells out the consequence: the user has to be directed to the Settings app. - **`expires`** is `'never'` or a number; today every Expo grant is permanent and reports `'never'`. A **permanent denial** is the combination `status: 'denied'` with `canAskAgain: false`. ## The three-way branch Treating permission as a boolean produces the classic dead screen: a button that calls request, which resolves as denied without any dialog, forever. The robust screen branches three ways: | state | what the screen shows | what the button does | |---|---|---| | `granted` | the nearby-cafes list | nothing to ask | | `undetermined`, or `denied` with `canAskAgain: true` | a one-line reason plus "Allow location" | calls the request function | | `denied` with `canAskAgain: false` | a reason plus "Open Settings" | calls `Linking.openSettings()` | `Linking.openSettings()` from `react-native` opens the app's own page in the system settings on both platforms; `expo-linking` re-exports the same function. ## Why the platforms reach canAskAgain: false differently The field is the same, but how each platform gets there differs: 1. **iOS** shows the prompt only while the status is still undetermined. Expo's iOS permissions service sets `canAskAgain` to `status != denied`, so a single denial makes it false and every later request resolves as denied with no dialog. 2. **Android** can let the app ask again after a first denial, and stops showing the dialog after the user refuses more firmly. Android does not report that distinction when you merely read the state, so Expo's Android permissions service records it itself: after a denial it asks whether the system would show a rationale, and when it would not, it stores the permission as blocked and reports `canAskAgain: false`. Two practical consequences follow. First, never infer permanence from `status` alone: on Android a `'denied'` response with `canAskAgain: true` is still askable. Second, `status` distinguishes a never-asked permission (`'undetermined'`) from a refused one, because Expo tracks on Android whether it has asked. ## Coming back from Settings Changing a permission in Settings does not notify the JavaScript side. The screen has to **re-read**: - If you use a permission hook such as `useForegroundPermissions`, call the third tuple element, the `getPermission` function, which refreshes the hook's state. - Otherwise call `getForegroundPermissionsAsync()` again when the screen regains focus or the app returns to the foreground. Offering an explicit "I've turned it on" button that re-reads the state is a simple, reliable fallback. ## Graceful degradation "Gracefully" is what interviewers are probing. For the nearby-cafes feature: - **Keep the app usable**: let the user type a neighbourhood or pick a city instead of blocking the screen. - **Explain the value in one sentence** before the prompt and on the Settings card, rather than a generic "we need your location". - **Do not nag**: show the Settings card where the feature lives, not as a modal on every launch. ## Traps interviewers listen for - **Collapsing to a boolean.** `if (!granted) request()` has no branch for the case where request can no longer prompt. - **Checking `status` without `canAskAgain`.** It works on iOS and fails on Android, where a first denial is usually still askable. - **Opening Settings without explaining why.** The system page gives no context; the sentence before the button is what persuades the user. - **Forgetting the re-read.** A user who enabled location and came back to an unchanged "location is off" card assumes the app is broken. ## Summary The screening answer fits in one breath: read `status` and `canAskAgain` together, request only while the system can still prompt, send the user to Settings with `Linking.openSettings()` once it cannot, and re-read with the get function when they return.

  • What does requestForegroundPermissionsAsync return when canAskAgain is already false?
    It resolves with the same denied response, `status: 'denied'` and `canAskAgain: false`, and no dialog appears. That is why a screen that only knows how to call request looks broken to a user who denied once: the button does nothing visible.
  • Why does Expo's Android implementation track canAskAgain itself instead of just reading it?
    Android does not say, when you merely read a permission, whether a denied permission is still askable or blocked. Expo's Android permissions service decides after each denial, using whether the system would show a rationale, stores the result, and reports it as `canAskAgain` on later reads.
  • How does the screen learn that the user enabled location in Settings?
    It does not get a notification. It has to re-read the state, either by calling the hook's `getPermission` function or `getForegroundPermissionsAsync()` again when the screen regains focus or the app returns to the foreground, or when the user taps an explicit re-check button.

A permission prompt is like a shop assistant who may knock on a customer's door only while the customer has not locked it. Once it is locked, knocking again achieves nothing; the only way in is to hand the customer the key, the Settings page, and wait for them to open it themselves.

saying these in an interview costs you the question

  • A denied status always means the app can never ask again.
  • Keep calling the request function; the dialog will eventually reappear.
  • The granted flag and canAskAgain carry the same information.
  • Changing a permission in Settings automatically updates the hook's state.
  • Block the whole app with a modal until location is granted.