First: do you need a camera screen?
If the feature is “let the user attach a photo” — a profile picture, a receipt, a listing image — you almost certainly want expo-image-picker, not a camera. It opens the system camera or gallery, handles its own permissions, and gives users the option to pick an existing photo, which is what most of them wanted anyway.
import * as ImagePicker from 'expo-image-picker';
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.7, // do this here, not later
});
if (!result.canceled) setPhotoUri(result.assets[0].uri);Build a real camera screen when the capture is the experience: a scanner with a framing overlay, a document capture with edge guides, a multi-shot flow where returning to a picker each time would be miserable. Everything below assumes you are in that case.
The live options
| Package | Status | Expo Go | Best for | Shape |
|---|---|---|---|---|
| expo-camera | Current, maintained | ✅ Yes | Photos, video, barcode scanning | CameraView + permission hook |
| react-native-vision-camera | Current, community | ❌ Dev build | Real-time frame processing, ML | Device hooks + worklets |
| expo-image-picker | Current | ✅ Yes | Just need a photo, any source | System UI — no camera screen to build |
| react-native-camera | ❌ Unmaintained | — | Nothing — migrate off | Most old tutorials use this |
The dividing line between the top two is frames. If you need to read every frame as it arrives and run something on it — detect a face, read text live, track a pose — Vision Camera’s frame processors exist for exactly that and expo-camera has no equivalent. If you need a picture or a video file at the end, expo-camera is less setup and less to maintain.
A capture screen with expo-camera
npx expo install expo-camera expo-image-manipulatorDeclare why you want the camera in app.json. This string is shown in the system dialog and is checked during App Store review — a vague one is a rejection:
// app.json
{
"expo": {
"plugins": [
["expo-camera", {
"cameraPermission": "Allow $(PRODUCT_NAME) to take photos of your receipts."
}]
]
}
}import { CameraView, useCameraPermissions } from 'expo-camera';
import * as ImageManipulator from 'expo-image-manipulator';
import { useRef, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
export default function CaptureScreen({ onCaptured }) {
const [permission, requestPermission] = useCameraPermissions();
const [facing, setFacing] = useState('back');
const [busy, setBusy] = useState(false);
const cameraRef = useRef(null);
// permission is null only while it is still loading
if (!permission) return <View style={styles.fill} />;
if (!permission.granted) {
return (
<View style={styles.center}>
<Text style={styles.explain}>
We need the camera to capture receipts. Photos stay on your device
until you save them.
</Text>
<Pressable style={styles.primary} onPress={requestPermission}>
<Text style={styles.primaryText}>Allow camera</Text>
</Pressable>
</View>
);
}
async function capture() {
if (busy) return; // double-tap guard — takePicture is slow
setBusy(true);
try {
const photo = await cameraRef.current.takePictureAsync({
quality: 0.8,
skipProcessing: true, // faster shutter on Android
// never base64: true
});
const resized = await ImageManipulator.manipulateAsync(
photo.uri,
[{ resize: { width: 1600 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG },
);
onCaptured(resized.uri);
} finally {
setBusy(false);
}
}
return (
<View style={styles.fill}>
<CameraView ref={cameraRef} style={styles.fill} facing={facing} />
<View style={styles.controls}>
<Pressable onPress={() => setFacing((f) => (f === 'back' ? 'front' : 'back'))}>
<Text style={styles.flip}>Flip</Text>
</Pressable>
<Pressable
style={[styles.shutter, busy && { opacity: 0.5 }]}
onPress={capture}
/>
</View>
</View>
);
}Three things in there are the difference between a demo and a shippable screen: an explanation before the system dialog, a busy guard so a double tap does not fire two captures, and a resize step before the URI leaves the function. The last one is covered in more depth in the image upload guide.
Scanning without a second package
Barcode and QR scanning is built into the same component — restrict the types you accept so the scanner is not firing on every stray barcode in frame, and guard against the callback running many times per second:
const handled = useRef(false);
<CameraView
style={StyleSheet.absoluteFillObject}
barcodeScannerSettings={{ barcodeTypes: ['qr', 'ean13'] }}
onBarcodeScanned={({ data }) => {
if (handled.current) return; // fires continuously otherwise
handled.current = true;
onScan(data);
}}
/>Without that ref you get the classic bug: one scan navigates forward twelve times. Full treatment in the QR scanner guide.
When you actually need Vision Camera
Vision Camera exposes the camera as devices and formats, and lets you run a worklet on every frame off the JavaScript thread. That is the capability you are buying:
import { Camera, useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
const device = useCameraDevice('back');
const { hasPermission, requestPermission } = useCameraPermission();
if (!hasPermission) return <PermissionPrompt onPress={requestPermission} />;
if (device == null) return <NoCameraFound />; // always handle this
<Camera style={StyleSheet.absoluteFill} device={device} isActive photo />Two costs come with it. It needs a development build, so no Expo Go and a native rebuild whenever it updates. And useCameraDevice can return null — on a simulator, on unusual hardware, or while the list is loading — so a screen that assumes a device exists crashes for a small but real slice of users.
The permission screen is the feature
Camera permission is one of the few taps in your app that a user can only make wrong once. Deny it and the system dialog never reappears — from then on the only route is Settings, and most people do not go. So the screen before the prompt matters more than the camera code after it: say what you capture, say where it goes, and trigger the dialog from a deliberate tap rather than on mount. If permission is already denied, detect it and deep-link into Settings instead of showing a button that silently does nothing.
Capture screens are also the fastest kind of screen to get wrong on a real device and never notice in a simulator, which is where a live preview helps. Describe it — “a receipt capture screen with a framing overlay, a flash toggle, and a review step before saving” — and ShipNative generates it as real React Native, permission flow included, running on your own phone.