React Native Camera, Image Picker, and Barcode Scanner with Expo

Learn how to capture photos, pick images from the gallery, and build a barcode scanner in React Native using expo-camera and expo-image-picker. Includes permissions handling, Vision Camera comparison, and troubleshooting tips for Expo SDK 55.

If you've built more than a couple of React Native apps, you've almost certainly hit the same trio of requirements: take a photo, pick an image from the gallery, or scan a barcode. They come up constantly. The good news? With Expo SDK 55 and the modern expo-camera and expo-image-picker libraries, these features are genuinely straightforward to implement.

This guide covers the full picture — from basic photo capture and gallery selection to building a production-ready QR code scanner. You'll learn the hook-based permissions API, handle the quirks that differ between iOS and Android, and understand when it makes sense to reach for react-native-vision-camera instead.

Prerequisites and Project Setup

Before diving in, make sure your setup looks something like this:

  • Node.js 20 LTS or later
  • Expo SDK 55 (or SDK 53+)
  • A physical device for camera testing — simulators won't cut it here
  • TypeScript configured (recommended but not strictly required)

Create a new project or work within an existing one:

npx create-expo-app@latest CameraApp
cd CameraApp

Then install the camera and image picker packages:

npx expo install expo-camera expo-image-picker

Both packages include Expo config plugins, so they work seamlessly with Continuous Native Generation (CNG). No manual native code changes needed — which is honestly one of the best parts of working within the Expo ecosystem.

Setting Up expo-camera: The CameraView Component

The expo-camera library gives you the CameraView component — a drop-in React component that renders a live camera preview. It handles photo capture, video recording, barcode scanning, and torch control right out of the box.

Configuring the Plugin in app.json

Add the expo-camera config plugin to your app.json to set permission messages and enable barcode scanning:

{
  "expo": {
    "plugins": [
      [
        "expo-camera",
        {
          "cameraPermission": "Allow $(PRODUCT_NAME) to access your camera to take photos and scan codes.",
          "microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone for video recording.",
          "recordAudioAndroid": true
        }
      ]
    ]
  }
}

Requesting Camera Permissions

The modern approach uses the useCameraPermissions hook. It returns both the current permission status and a function to request permission — clean and simple:

import { CameraView, useCameraPermissions } from 'expo-camera';
import { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';

export default function CameraScreen() {
  const [permission, requestPermission] = useCameraPermissions();

  if (!permission) {
    // Permissions are still loading
    return <View />;
  }

  if (!permission.granted) {
    return (
      <View style={styles.container}>
        <Text style={styles.message}>
          Camera access is needed to take photos
        </Text>
        <Button onPress={requestPermission} title="Grant Permission" />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <CameraView style={styles.camera} facing="back" />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center' },
  camera: { flex: 1 },
  message: { textAlign: 'center', paddingBottom: 10 },
});

One thing to watch out for: if the user permanently denies camera permission, the system won't show the dialog again. You'll need to guide them to device settings instead:

import { Linking } from 'react-native';

// Inside your permission-denied UI:
<Button
  title="Open Settings"
  onPress={() => Linking.openSettings()}
/>

Taking Photos with CameraView

To capture photos, grab a ref to the CameraView component and call takePictureAsync. The captured image gets saved to the app's cache directory automatically.

import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRef, useState } from 'react';
import {
  Button, Image, StyleSheet, Text, TouchableOpacity, View,
} from 'react-native';

export default function PhotoCapture() {
  const [permission, requestPermission] = useCameraPermissions();
  const [photo, setPhoto] = useState(null);
  const [facing, setFacing] = useState('back');
  const cameraRef = useRef(null);

  const takePhoto = async () => {
    if (!cameraRef.current) return;

    const result = await cameraRef.current.takePictureAsync({
      quality: 0.8,
      base64: false,
      exif: true,
    });

    setPhoto(result);
  };

  const toggleFacing = () => {
    setFacing((prev) => (prev === 'back' ? 'front' : 'back'));
  };

  if (!permission?.granted) {
    return (
      <View style={styles.container}>
        <Text>Camera permission required</Text>
        <Button onPress={requestPermission} title="Grant Permission" />
      </View>
    );
  }

  if (photo) {
    return (
      <View style={styles.container}>
        <Image source={{ uri: photo.uri }} style={styles.preview} />
        <Text>Size: {photo.width} x {photo.height}</Text>
        <Button title="Take Another" onPress={() => setPhoto(null)} />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <CameraView
        ref={cameraRef}
        style={styles.camera}
        facing={facing}
      >
        <View style={styles.controls}>
          <TouchableOpacity onPress={toggleFacing} style={styles.button}>
            <Text style={styles.buttonText}>Flip</Text>
          </TouchableOpacity>
          <TouchableOpacity onPress={takePhoto} style={styles.captureButton}>
            <View style={styles.captureInner} />
          </TouchableOpacity>
        </View>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
  preview: { flex: 1, resizeMode: 'contain' },
  controls: {
    flex: 1,
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'flex-end',
    marginBottom: 36,
    gap: 24,
  },
  button: {
    backgroundColor: 'rgba(0,0,0,0.5)',
    padding: 12,
    borderRadius: 8,
  },
  buttonText: { color: 'white', fontSize: 16 },
  captureButton: {
    width: 72,
    height: 72,
    borderRadius: 36,
    backgroundColor: 'white',
    justifyContent: 'center',
    alignItems: 'center',
  },
  captureInner: {
    width: 62,
    height: 62,
    borderRadius: 31,
    backgroundColor: 'white',
    borderWidth: 2,
    borderColor: '#ccc',
  },
});

takePictureAsync Options Explained

The takePictureAsync method accepts a configuration object with these properties:

  • quality (number, 0–1): Compression level. 0.8 is a good sweet spot between file size and clarity. Bump it to 1 only when you really need maximum quality.
  • base64 (boolean): When true, returns the image as a Base64 string alongside the file URI. Handy for direct API uploads, but it's noticeably slower for large images.
  • exif (boolean): Includes EXIF metadata like GPS coordinates, camera info, and orientation.
  • skipProcessing (boolean): Skips post-capture processing for faster capture. Worth enabling in burst-mode scenarios.

Picking Images from the Gallery with expo-image-picker

The expo-image-picker library gives you system-native UI for selecting images and videos from the device's media library. You can also use it to take a photo through the OS camera app — more on that in a moment.

Configuring the Plugin

{
  "expo": {
    "plugins": [
      [
        "expo-image-picker",
        {
          "photosPermission": "Allow $(PRODUCT_NAME) to access your photos for profile pictures and uploads.",
          "cameraPermission": "Allow $(PRODUCT_NAME) to take photos."
        }
      ]
    ]
  }
}

Selecting a Single Image

import * as ImagePicker from 'expo-image-picker';
import { useState } from 'react';
import { Button, Image, StyleSheet, View } from 'react-native';

export default function SingleImagePicker() {
  const [image, setImage] = useState(null);

  const pickImage = async () => {
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ['images'],
      allowsEditing: true,
      aspect: [4, 3],
      quality: 0.8,
    });

    if (!result.canceled) {
      setImage(result.assets[0].uri);
    }
  };

  return (
    <View style={styles.container}>
      <Button title="Pick an Image" onPress={pickImage} />
      {image && (
        <Image source={{ uri: image }} style={styles.image} />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
  image: { width: 300, height: 300, marginTop: 20, borderRadius: 8 },
});

The mediaTypes Breaking Change

This one catches a lot of people off guard. In recent versions of expo-image-picker, the mediaTypes option changed from an enum to a string array. If you're upgrading from an older SDK, update your code like this:

// OLD (deprecated) — do not use
mediaTypes: ImagePicker.MediaTypeOptions.Images

// NEW (current) — use this
mediaTypes: ['images']

// For images and videos together:
mediaTypes: ['images', 'videos']

Selecting Multiple Images

Enable multi-select by setting allowsMultipleSelection to true. You can also cap the number of selections with selectionLimit:

const pickMultipleImages = async () => {
  const result = await ImagePicker.launchImageLibraryAsync({
    mediaTypes: ['images'],
    allowsMultipleSelection: true,
    selectionLimit: 5,
    quality: 0.7,
  });

  if (!result.canceled) {
    const uris = result.assets.map((asset) => asset.uri);
    setImages(uris);
  }
};

Platform note: Multi-selection works reliably on both iOS and Android in SDK 55. On iOS though, selecting a large number of images at once (say, 10+) can occasionally cause slowdowns while the system processes each asset. Setting selectionLimit helps keep things snappy.

Taking a Photo via the System Camera

You can also use expo-image-picker to open the device's native camera app instead of building a custom CameraView with expo-camera. This is the simpler route when you just need a quick photo without all the UI customization:

const takePhoto = async () => {
  const permissionResult =
    await ImagePicker.requestCameraPermissionsAsync();

  if (!permissionResult.granted) {
    alert('Camera permission is required to take photos.');
    return;
  }

  const result = await ImagePicker.launchCameraAsync({
    allowsEditing: true,
    aspect: [1, 1],
    quality: 0.8,
  });

  if (!result.canceled) {
    setImage(result.assets[0].uri);
  }
};

Building a QR Code and Barcode Scanner

Here's something I really appreciate about expo-camera: barcode scanning is built directly into the CameraView component. No extra packages to install. Under the hood, it uses Google Code Scanner (via Play Services) on Android and DataScannerViewController (VisionKit) on iOS 16+.

Basic Barcode Scanner

import { CameraView, useCameraPermissions } from 'expo-camera';
import { useState } from 'react';
import {
  Alert, Button, StyleSheet, Text, View,
} from 'react-native';

export default function BarcodeScanner() {
  const [permission, requestPermission] = useCameraPermissions();
  const [scanned, setScanned] = useState(false);

  const handleBarcodeScanned = ({ type, data }) => {
    setScanned(true);
    Alert.alert(
      'Barcode Scanned',
      `Type: ${type}\nData: ${data}`,
      [
        {
          text: 'Scan Again',
          onPress: () => setScanned(false),
        },
      ]
    );
  };

  if (!permission?.granted) {
    return (
      <View style={styles.container}>
        <Text style={styles.text}>Camera access required for scanning</Text>
        <Button onPress={requestPermission} title="Grant Permission" />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <CameraView
        style={StyleSheet.absoluteFillObject}
        onBarcodeScanned={scanned ? undefined : handleBarcodeScanned}
        barcodeScannerSettings={{
          barcodeTypes: ['qr', 'ean13', 'ean8', 'code128', 'code39', 'upc_a'],
        }}
      />
      <View style={styles.overlay}>
        <View style={styles.scanArea} />
        <Text style={styles.hint}>
          Point your camera at a barcode
        </Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center' },
  text: { textAlign: 'center', marginBottom: 16 },
  overlay: {
    ...StyleSheet.absoluteFillObject,
    justifyContent: 'center',
    alignItems: 'center',
  },
  scanArea: {
    width: 250,
    height: 250,
    borderWidth: 2,
    borderColor: '#00ff00',
    borderRadius: 12,
    backgroundColor: 'transparent',
  },
  hint: {
    color: 'white',
    fontSize: 16,
    marginTop: 20,
    textShadowColor: 'black',
    textShadowRadius: 4,
  },
});

Supported Barcode Types

The barcodeScannerSettings.barcodeTypes array accepts these values:

  • 1D codes: ean13, ean8, upc_a, upc_e, code39, code93, code128, codabar, itf14
  • 2D codes: qr, pdf417, aztec, datamatrix

A quick tip: only specify the barcode types you actually need. The fewer types the detector checks against each frame, the faster scanning will be.

Pausing and Resuming the Scanner

There's a key pattern worth understanding here. You pause scanning by passing undefined to onBarcodeScanned. This prevents the callback from firing continuously after the first successful scan:

// Scanning is active
<CameraView onBarcodeScanned={handleBarcodeScanned} />

// Scanning is paused (after a code is detected)
<CameraView onBarcodeScanned={undefined} />

That's exactly what the scanned state variable controls in the example above. Once a barcode is detected, scanned flips to true, which passes undefined instead of the handler. Simple, but effective.

expo-camera vs. react-native-vision-camera: When to Use Which

While expo-camera covers most use cases, react-native-vision-camera is the heavier-duty alternative for apps that need advanced camera control. Here's how they stack up:

Feature expo-camera react-native-vision-camera
Managed workflow (Expo Go) Yes No (dev client required)
Photo capture Yes Yes
Video recording Yes Yes
Barcode scanning Built-in Built-in (+ frame processors)
Frame processors (real-time ML) No Yes (JSI-powered)
Manual focus / exposure Limited Full control
Pinch-to-zoom Basic Smooth, animated
GS1 DataBar codes No Yes
Scan from image files No Yes
Update frequency SDK release cycle (~3x/year) Frequent independent releases

So, the short version:

Go with expo-camera when you want a simple, well-integrated solution for photo capture, basic video recording, and standard barcode scanning within the Expo managed workflow.

Reach for react-native-vision-camera when you need frame processors for real-time ML (think face detection or object recognition), fine-grained camera controls, GS1 DataBar scanning, or the ability to scan barcodes from saved images.

Quick Vision Camera Setup with Expo

If you decide to go the Vision Camera route in an Expo project, install it with the config plugin:

npx expo install react-native-vision-camera

Then add this to your app.json:

{
  "expo": {
    "plugins": [
      [
        "react-native-vision-camera",
        {
          "cameraPermissionText": "Allow $(PRODUCT_NAME) to access the camera.",
          "enableCodeScanner": true,
          "enableFrameProcessors": true
        }
      ]
    ]
  }
}

A basic Vision Camera barcode scanner looks like this:

import {
  Camera,
  useCameraDevice,
  useCodeScanner,
} from 'react-native-vision-camera';
import { StyleSheet } from 'react-native';

export default function VisionScanner() {
  const device = useCameraDevice('back');

  const codeScanner = useCodeScanner({
    codeTypes: ['qr', 'ean-13', 'code-128'],
    onCodeScanned: (codes) => {
      const value = codes[0]?.value;
      if (value) {
        console.log('Scanned:', value);
      }
    },
  });

  if (!device) return null;

  return (
    <Camera
      style={StyleSheet.absoluteFill}
      device={device}
      isActive={true}
      codeScanner={codeScanner}
    />
  );
}

Keep in mind: Vision Camera requires a custom dev client — it won't work in Expo Go. You'll need to run npx expo run:ios or npx expo run:android, or use EAS Build.

Permissions Best Practices

Getting camera and media library permissions right is critical for a good user experience (and App Store approval). These guidelines have saved me plenty of headaches.

1. Request Permissions Just-in-Time

Don't request camera permission the moment the app launches. Wait until the user taps a button that actually needs camera access. This gives them context for why you're asking, and it genuinely improves grant rates.

2. Explain Before Requesting

Show a brief explanation screen before triggering the native permission dialog. Users are much more likely to tap "Allow" when they understand the reason behind the request:

const requestWithExplanation = async () => {
  // Show your custom explanation UI first, then:
  const { status } = await Camera.requestCameraPermissionsAsync();

  if (status === 'granted') {
    // Proceed to camera screen
  } else {
    // Show fallback UI explaining how to enable in Settings
  }
};

3. Handle Permanent Denials Gracefully

On both iOS and Android, if a user denies a permission twice (or selects "Don't Ask Again" on Android), the system won't show the dialog again. Your app needs to detect this and redirect to device settings:

import { Linking } from 'react-native';

if (!permission.canAskAgain && !permission.granted) {
  // Permission permanently denied — open device settings
  Linking.openSettings();
}

4. iOS App Store Compliance for expo-camera

When publishing to the App Store, Apple asks about encryption usage. If you're using expo-camera but not expo-secure-store or other encryption libraries, you can typically set ios.config.usesNonExemptEncryption to false in your app config. It's a small detail, but it'll prevent a hold-up during review.

Practical Example: Profile Photo Screen

Let's put it all together. Here's a complete, real-world example combining expo-image-picker and expo-camera — a profile photo screen where users can either snap a new photo or choose one from their gallery:

import * as ImagePicker from 'expo-image-picker';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRef, useState } from 'react';
import {
  Button, Image, Modal, StyleSheet, Text,
  TouchableOpacity, View,
} from 'react-native';

export default function ProfilePhotoScreen() {
  const [avatar, setAvatar] = useState(null);
  const [showCamera, setShowCamera] = useState(false);
  const [cameraPermission, requestCameraPermission] = useCameraPermissions();
  const cameraRef = useRef(null);

  const pickFromGallery = async () => {
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ['images'],
      allowsEditing: true,
      aspect: [1, 1],
      quality: 0.8,
    });

    if (!result.canceled) {
      setAvatar(result.assets[0].uri);
    }
  };

  const openCamera = async () => {
    if (!cameraPermission?.granted) {
      const { granted } = await requestCameraPermission();
      if (!granted) return;
    }
    setShowCamera(true);
  };

  const capturePhoto = async () => {
    const photo = await cameraRef.current?.takePictureAsync({
      quality: 0.8,
    });
    if (photo) {
      setAvatar(photo.uri);
      setShowCamera(false);
    }
  };

  return (
    <View style={styles.container}>
      <View style={styles.avatarContainer}>
        {avatar ? (
          <Image source={{ uri: avatar }} style={styles.avatar} />
        ) : (
          <View style={[styles.avatar, styles.placeholder]}>
            <Text style={styles.placeholderText}>No Photo</Text>
          </View>
        )}
      </View>

      <View style={styles.actions}>
        <Button title="Choose from Gallery" onPress={pickFromGallery} />
        <Button title="Take a Photo" onPress={openCamera} />
      </View>

      <Modal visible={showCamera} animationType="slide">
        <CameraView ref={cameraRef} style={styles.camera} facing="front">
          <View style={styles.cameraControls}>
            <TouchableOpacity onPress={() => setShowCamera(false)}>
              <Text style={styles.cancelText}>Cancel</Text>
            </TouchableOpacity>
            <TouchableOpacity onPress={capturePhoto} style={styles.shutter} />
          </View>
        </CameraView>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
  avatarContainer: { marginBottom: 24 },
  avatar: { width: 150, height: 150, borderRadius: 75 },
  placeholder: {
    backgroundColor: '#e0e0e0',
    justifyContent: 'center',
    alignItems: 'center',
  },
  placeholderText: { color: '#999' },
  actions: { gap: 12 },
  camera: { flex: 1 },
  cameraControls: {
    flex: 1,
    flexDirection: 'row',
    justifyContent: 'space-around',
    alignItems: 'flex-end',
    paddingBottom: 40,
  },
  cancelText: { color: 'white', fontSize: 18 },
  shutter: {
    width: 70,
    height: 70,
    borderRadius: 35,
    backgroundColor: 'white',
    borderWidth: 4,
    borderColor: '#ccc',
  },
});

Performance Tips and Common Pitfalls

Avoid Unnecessary Base64 Encoding

Only set base64: true in takePictureAsync when you genuinely need the raw data for an API upload. Base64 encoding large images is slow and eats up memory fast. In most cases, you're better off using the file URI with a FormData upload.

Clean Up Cached Images

Both expo-camera and expo-image-picker save images to the app's cache directory, and those files pile up over time. Use expo-file-system to periodically clean house:

import * as FileSystem from 'expo-file-system';

const clearImageCache = async () => {
  const cacheDir = FileSystem.cacheDirectory + 'Camera/';
  const dirInfo = await FileSystem.getInfoAsync(cacheDir);
  if (dirInfo.exists) {
    await FileSystem.deleteAsync(cacheDir, { idempotent: true });
  }
};

Optimize Image Quality for Your Use Case

A quality value of 1.0 produces full-resolution images that can be several megabytes each. For profile photos or thumbnails, 0.50.7 is usually more than enough — and it's way faster to process and upload.

Handle Camera Ready State

Always wait for the onCameraReady callback before calling takePictureAsync. Jumping the gun before the camera hardware initializes will either throw an error or give you a lovely black image:

const [isReady, setIsReady] = useState(false);

<CameraView
  ref={cameraRef}
  onCameraReady={() => setIsReady(true)}
  style={styles.camera}
/>

// Disable the capture button until ready
<Button
  title="Capture"
  onPress={takePhoto}
  disabled={!isReady}
/>

Troubleshooting Common Issues

Camera Shows a Black Screen

Nine times out of ten, this happens because you're testing on a simulator. iOS Simulator and Android Emulator have limited (or no) camera support. Always test camera features on a physical device. If it's happening on a real device, make sure no other app is hogging the camera.

onBarcodeScanned Never Fires on iOS

On iOS 16+, barcode scanning relies on DataScannerViewController, which requires specific entitlements. Double-check that your app.json has the expo-camera plugin configured and that you've rebuilt the native project after adding it. Running npx expo prebuild --clean followed by a fresh build usually sorts this out.

Permission Dialog Does Not Appear

If you've previously denied the permission, iOS and Android won't show the dialog again. Check permission.canAskAgain — if it returns false, your only option is to redirect the user to device settings with Linking.openSettings().

Image Picker Returns HEIC Files on iOS

Starting with SDK 54, when allowsEditing is false and videoExportPreset is Passthrough (the defaults), iOS returns the original file format — which may be HEIC or AVIF. If your backend expects JPEG, either set allowsEditing: true or use expo-image-manipulator to convert the format before uploading.

Frequently Asked Questions

Can I use expo-camera in Expo Go?

Yes! expo-camera is included in the Expo Go client and works for photo capture and barcode scanning. However, react-native-vision-camera is not bundled with Expo Go — you'll need a custom dev client or EAS Build for that.

How do I scan barcodes from a saved image instead of the live camera?

expo-camera doesn't support scanning barcodes from image files — it only works with the live camera feed. If you need to scan from saved images, react-native-vision-camera is the way to go. It supports image-based barcode detection through its code scanner API.

What is the difference between expo-image-picker and expo-camera for taking photos?

expo-image-picker with launchCameraAsync opens the device's built-in camera app and hands you back the result. You get zero control over the camera UI. expo-camera with CameraView, on the other hand, renders a fully customizable camera preview inside your app — you control the overlays, buttons, styling, and can layer on features like barcode scanning. Use expo-image-picker when you just need a quick capture, and expo-camera when you want the custom experience.

Is expo-barcode-scanner deprecated?

Yes, it is. The standalone expo-barcode-scanner package has been deprecated in favor of the barcode scanning functionality now built directly into expo-camera. To migrate, replace BarCodeScanner with CameraView and use the onBarcodeScanned prop along with barcodeScannerSettings.

How do I limit which barcode types are scanned to improve performance?

Pass a barcodeScannerSettings object to CameraView with only the specific barcodeTypes your app needs. For example, if you only care about QR codes, use barcodeScannerSettings={{ barcodeTypes: ['qr'] }}. Fewer types means less work per frame, which translates directly to faster scanning.

About the Author Editorial Team

Our team of expert writers and editors.