Asking Well

Explain before the system dialog, keep the screen usable after a refusal, and send blocked users to Settings.

SyntaxLinking.openSettings(): Promise<void>

You get one system dialog per permission on iOS, and it is written by the operating system. The only part you control is what happens either side of it. That makes the sequence worth designing.

Explain first, then ask

Show your own screen, in your own words, saying what the feature does and what the app will do with the data. Put the request behind a button on that screen. If someone taps away instead, you have spent nothing: the system dialog never appeared, so it is still available later.

A refusal is not an error

Do not throw, do not show a blank screen, do not loop the request. Offer the path that does not need the permission: type a town instead of using GPS, pick an existing photo instead of taking one. An app that becomes useless after a no will be deleted rather than reconsidered.

Deep-link to Settings

Linking.openSettings() returns Promise<void> and opens this app's page in the Settings app. Offer it only when canAskAgain is false, because while the dialog can still appear, sending someone to Settings is a detour.

Re-read when you come back

Settings is a different app. Your screen stays mounted and its cached permission object is now stale. Subscribe to AppState and re-read the status on active, or the user flips the switch, returns, and still sees the refusal message.

Example

Example · tsx
import { useEffect } from 'react';
import { AppState, Linking, Pressable, StyleSheet, Text, View } from 'react-native';
import { useCameraPermissions } from 'expo-camera';

export default function ScanPrompt() {
  // The third element re-reads the status without showing any dialog.
  const [permission, requestPermission, getPermission] = useCameraPermissions();

  // Hooks run unconditionally, before any branch or early return.
  useEffect(() => {
    const subscription = AppState.addEventListener('change', (state) => {
      // Coming back from Settings: the cached answer may be out of date.
      if (state === 'active') { void getPermission(); }
    });
    return () => subscription.remove();
  }, [getPermission]);

  if (permission === null || !permission.granted) {
    const blocked = permission !== null && !permission.canAskAgain;
    return (
      <View style={styles.screen}>
        <Text style={styles.title}>Scan a receipt</Text>
        <Text style={styles.body}>
          The camera reads the total off the paper. Nothing leaves the phone
          until you tap Save.
        </Text>
        <Pressable
          style={styles.button}
          onPress={() => { void (blocked ? Linking.openSettings() : requestPermission()); }}
        >
          <Text style={styles.label}>{blocked ? 'Open Settings' : 'Allow camera'}</Text>
        </Pressable>
      </View>
    );
  }

  return (
    <View style={styles.screen}>
      <Text style={styles.title}>Point at the receipt</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 22, fontWeight: '600', marginBottom: 8 },
  body: { fontSize: 16, marginBottom: 20 },
  button: { backgroundColor: '#0A7EA4', borderRadius: 8, padding: 14 },
  label: { color: '#ffffff', fontSize: 16, textAlign: 'center' },
});

When to use it

  • Putting an explanation screen in front of the camera dialog on a document-scanning flow.
  • Keeping a map screen usable after location is refused by letting the user search instead.
  • Offering an Open Settings button once canAskAgain comes back false.

More examples

A refusal that falls back instead of failing

Tapping the button either fills in coordinates or reveals a text field. The feature still works after a no, which also means you can ask again later from a different screen without having trained the user to refuse. Use this shape for anything where a manual equivalent exists.

Example · tsx
import { useState } from 'react';
import * as Location from 'expo-location';
import { Button, StyleSheet, Text, TextInput, View } from 'react-native';

export default function PlacePicker() {
  const [place, setPlace] = useState('');
  const [manual, setManual] = useState(false);

  async function useMyLocation() {
    const response = await Location.requestForegroundPermissionsAsync();
    if (!response.granted) {
      // No alert, no thrown error: the screen switches to the typed path.
      setManual(true);
      return;
    }
    const position = await Location.getCurrentPositionAsync({});
    setPlace(
      `${position.coords.latitude.toFixed(2)}, ${position.coords.longitude.toFixed(2)}`
    );
  }

  return (
    <View style={styles.screen}>
      <Button title="Use my location" onPress={() => { void useMyLocation(); }} />
      {manual ? (
        <TextInput
          style={styles.input}
          placeholder="Type a town instead"
          value={place}
          onChangeText={setPlace}
        />
      ) : (
        <Text style={styles.body}>{place}</Text>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, justifyContent: 'center', padding: 24 },
  body: { fontSize: 16, marginTop: 12 },
  input: { borderColor: '#cccccc', borderWidth: 1, borderRadius: 8, marginTop: 12, padding: 12 },
});

The gate as a reusable hook

Once two or three screens need the camera, the branching belongs in a hook. Each screen then renders one of four cases and never repeats the `canAskAgain` logic. The hook inside it is still called on every render, which is what keeps React's hook order stable.

Example · tsx
import { useCameraPermissions } from 'expo-camera';

export type GateState = 'loading' | 'ask' | 'blocked' | 'ready';

// One place that knows the four states, so screens only map them to markup.
export function useCameraGate(): { state: GateState; ask: () => void } {
  // The hook is called unconditionally, at the top, every render.
  const [permission, requestPermission] = useCameraPermissions();

  let state: GateState = 'loading';
  if (permission !== null) {
    state = permission.granted
      ? 'ready'
      : permission.canAskAgain ? 'ask' : 'blocked';
  }

  return { state, ask: () => { void requestPermission(); } };
}

An honest blocked state

A small component for the state you cannot recover from in-app: it names the switch the user is looking for rather than saying permission denied. Note the template literal wrapped in a Text, because a bare string sitting directly inside a View throws at runtime.

Example · tsx
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';

type BlockedNoticeProps = {
  feature: string;
  settingName: string;
};

export default function BlockedNotice({ feature, settingName }: BlockedNoticeProps) {
  return (
    <View style={styles.box}>
      <Text style={styles.body}>
        {`${feature} is turned off. Open Settings and switch on ${settingName}.`}
      </Text>
      <Pressable onPress={() => { void Linking.openSettings(); }}>
        <Text style={styles.link}>Open Settings</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  box: { backgroundColor: '#f2f2f2', borderRadius: 8, padding: 16 },
  body: { fontSize: 15, marginBottom: 8 },
  link: { color: '#0A7EA4', fontSize: 15, fontWeight: '600' },
});

Discussion

  • Be the first to comment on this lesson.