"Should I use react-native-permissions or a different library?" — this question shows up in nearly every React Native project that needs access to device hardware like camera, location, or microphone. It sounds deceptively simple, but the right answer depends entirely on how your project is structured. Getting this decision wrong early creates a cascade of configuration issues that tend to surface at the worst possible time: right before an App Store or Google Play submission.
This guide walks through the decision framework, provides working implementation examples for both approaches, and shows how Claude Code can accelerate the entire setup process — from the initial library choice through to the edge cases that catch most developers off guard.
Your Workflow Determines the Answer
There's no universally "better" library. The correct choice follows directly from your project's workflow type.
If you're using Expo Managed Workflow, use Expo's individual permission packages: expo-location, expo-camera, expo-media-library, expo-contacts, and so on. The older expo-permissions package was deprecated several years ago, but it still appears in older blog posts and Stack Overflow answers, which is why developers sometimes start with it and run into problems.
Each Expo SDK package now bundles its own permissions handling. The configuration integrates directly with app.json, so you don't need to manually edit Info.plist or AndroidManifest.xml. When the managed build system runs, it generates the native configuration files from your app.json automatically.
If you're using Expo Bare Workflow or React Native CLI, react-native-permissions is the current community standard. It provides a unified API that abstracts the differences between iOS and Android, letting you write one set of permission logic rather than maintaining separate implementations for each platform.
When you describe your project structure to Claude Code, it makes this determination for you and generates the appropriate implementation — the right library, with the right configuration files, for your setup.
Core Implementation with react-native-permissions
For a bare workflow project, here's the kind of implementation Claude Code produces when you ask it to set up camera and location permissions:
import { Platform } from 'react-native';
import {
PERMISSIONS,
RESULTS,
check,
request,
openSettings,
} from 'react-native-permissions';
// Platform-specific permission constants
const CAMERA_PERMISSION =
Platform.OS === 'ios'
? PERMISSIONS.IOS.CAMERA
: PERMISSIONS.ANDROID.CAMERA;
const LOCATION_PERMISSION =
Platform.OS === 'ios'
? PERMISSIONS.IOS.LOCATION_WHEN_IN_USE
: PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION;
/**
* Request camera permission.
* If permanently denied (BLOCKED), redirects to system Settings and returns false.
*/
export async function requestCameraPermission(): Promise<boolean> {
// Always check current status before triggering a dialog
const currentStatus = await check(CAMERA_PERMISSION);
if (currentStatus === RESULTS.GRANTED) {
return true;
}
if (currentStatus === RESULTS.BLOCKED) {
// BLOCKED means the user has permanently denied the permission.
// The system dialog will NOT appear again — redirect to Settings instead.
await openSettings();
return false;
}
// Status is DENIED (first-time request) — show the system dialog
const result = await request(CAMERA_PERMISSION);
return result === RESULTS.GRANTED;
}The most important design decision here is separating check() from request(). On iOS, once a user has permanently denied a permission, the RESULTS.BLOCKED state is set and calling request() does absolutely nothing — the system dialog won't appear. This is one of the most frequently misunderstood behaviors in React Native permissions handling.
Code that skips the check() and calls request() directly appears to work correctly on the first run. Then, after the user denies the permission once, every subsequent call silently fails with no feedback. That kind of intermittent, user-state-dependent bug is genuinely difficult to reproduce and debug.
The corresponding Info.plist and AndroidManifest.xml entries that Claude Code also generates look like this:
<!-- Info.plist (iOS) -->
<key>NSCameraUsageDescription</key>
<string>Camera access is required to take photos.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Location access is used to show nearby results.</string><!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />Expo Managed Workflow: The app.json Approach
For Expo managed workflow projects, the same location permission using expo-location looks like this:
import * as Location from 'expo-location';
export async function requestLocationPermission(): Promise<boolean> {
// requestForegroundPermissionsAsync handles the check + request in one call
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
// You can check status === 'denied' vs 'undetermined' for more granular handling
console.warn('Location permission not granted. Status:', status);
return false;
}
return true;
}And the app.json configuration that drives the native setup:
{
"expo": {
"ios": {
"infoPlist": {
"NSLocationWhenInUseUsageDescription": "Used to display your current location on the map."
}
},
"android": {
"permissions": ["ACCESS_FINE_LOCATION"]
}
}
}A key advantage of the Expo approach is that the app.json acts as the single source of truth. When you tell Claude Code to "update app.json as well," it generates both the TypeScript implementation and the configuration changes in one pass, keeping them consistent. Missing the app.json update is one of the most common reasons permission-related changes work locally but fail in a production build.
Building a Custom Hook with Permission State Caching
Both approaches benefit from centralizing permission state in a custom hook rather than checking permissions on every component mount. Here's a pattern Claude Code can generate when you ask for a reusable hook:
import { useEffect, useState } from 'react';
import { PERMISSIONS, RESULTS, check, request, openSettings } from 'react-native-permissions';
import { Platform } from 'react-native';
type PermissionStatus = 'undetermined' | 'granted' | 'denied' | 'blocked';
export function useCameraPermission() {
const [status, setStatus] = useState<PermissionStatus>('undetermined');
const [isChecking, setIsChecking] = useState(true);
const permission =
Platform.OS === 'ios' ? PERMISSIONS.IOS.CAMERA : PERMISSIONS.ANDROID.CAMERA;
useEffect(() => {
// Check once on mount — don't re-check on every render
check(permission)
.then((result) => {
if (result === RESULTS.GRANTED) setStatus('granted');
else if (result === RESULTS.BLOCKED) setStatus('blocked');
else if (result === RESULTS.DENIED) setStatus('denied');
else setStatus('undetermined');
})
.finally(() => setIsChecking(false));
}, []);
const requestPermission = async () => {
if (status === 'blocked') {
await openSettings();
return false;
}
const result = await request(permission);
const granted = result === RESULTS.GRANTED;
setStatus(granted ? 'granted' : 'denied');
return granted;
};
return { status, isChecking, requestPermission };
}This pattern avoids the common mistake of triggering repeated permission requests on component re-renders. The permission is checked once on mount and cached in component state. From there, requestPermission handles the BLOCKED redirect and updates the cached status after the user responds.
Three Common Pitfalls
Pitfall 1: UNAVAILABLE on the iOS Simulator
Some permissions — camera, Bluetooth, motion sensors, NFC — return UNAVAILABLE in the iOS simulator because the underlying hardware doesn't exist. Many developers encounter this early in development and assume their implementation is broken when it's actually behaving correctly.
Asking Claude Code "which permissions return UNAVAILABLE on the iOS simulator and how should my code handle each case?" gives you a quick reference map of what needs real device testing versus what can be covered with mocks or conditional skips.
Pitfall 2: The Privacy Manifest Requirement (iOS)
Since 2024, Apple requires apps that access certain privacy-sensitive APIs to include a PrivacyInfo.xcprivacy file describing how those APIs are used. If you're using react-native-permissions with camera, location, contacts, or other sensitive hardware, and your PrivacyInfo.xcprivacy file is missing or incomplete, your App Store submission will be rejected.
Claude Code can generate the correct PrivacyInfo.xcprivacy content when you describe which permissions your app uses. This is one of those requirements that isn't well-documented in the main React Native permissions guides but shows up reliably as a rejection reason.
Pitfall 3: Android Permission Groups
On Android, some permissions belong to the same permission group (for example, READ_CONTACTS and WRITE_CONTACTS are both in the CONTACTS group). When the user grants one permission in a group, Android may automatically grant others in the same group. This can cause your permission checks to return GRANTED for permissions you never explicitly requested, which can mask bugs in your implementation.
Claude Code handles this correctly when generating permission logic, but if you're reviewing existing code, it's worth verifying that the code isn't relying on implicit group grants.
How to Prompt Claude Code Effectively
The most reliable approach is leading with your project constraints before the implementation request:
This is an Expo bare workflow project.
Minimum targets: iOS 16.0 and Android API 33.
Library: react-native-permissions v4.
Implement a custom hook (usePermissions) that manages camera, microphone,
and push notification permissions.
For each permission, handle GRANTED / BLOCKED / DENIED states.
When BLOCKED, show an alert explaining why the permission is needed,
with a button that opens the system Settings app.
Also generate the required Info.plist entries and AndroidManifest.xml entries.
Providing the library version prevents Claude Code from generating code that uses a deprecated API. Providing minimum OS targets helps it apply the correct conditional behavior, since some permission APIs changed behavior between iOS versions.
Permissions management sits at the intersection of user experience, app functionality, and store submission compliance. The BLOCKED state handling, simulator behavior, and Privacy Manifest requirement are the three areas where most implementations fall short — not because they're complicated, but because they're easy to skip when you're focused on getting the core feature working.
Getting the foundation right here saves a significant amount of reactive debugging in the weeks before launch.
For a broader look at integrating Claude Code into your Expo/React Native workflow, see Claude Code × Expo/React Native — Accelerating Cross-Platform App Development.
Start with a Claude Code review of your existing permissions code — ask it to check whether your BLOCKED state handling is correct and whether your Privacy Manifest is complete. That's where the most common issues hide.