React Native Camera Integration with VisionCamera
AI generated
RN
native
React Native · VisionCamera · iOS · Android
React Native Camera Integration with VisionCamera
from photo capture to a native frame processor plugin

react-native-camera is unmaintained, and expo-camera is solid but limited for real frame processing. VisionCamera solves camera integration in React Native through JSI, running directly on a native thread and giving synchronous access to every single camera frame, not just to finished photos. This article walks through how the camera, frame processors, QR code scanning, and native plugins fit together.

19 min read VisionCamera · Frame Processors · Worklets iOS · Android · Swift · Kotlin

1. Why VisionCamera is the right choice

Anyone who built a camera integration in React Native a few years ago almost automatically ended up with react-native-camera. That package is now officially unmaintained, no longer receives updates for newer iOS and Android versions, and increasingly causes build problems with current React Native versions. The community usually recommends expo-camera as a replacement, which delivers solid basic photo and video functionality and integrates well into the Expo workflow. For simple use cases like a profile picture upload, that is often enough.

As soon as requirements go beyond plain photo and video capture, for example live object detection, face filters, or custom barcode scanning, expo-camera hits a wall because it does not offer structured access to individual camera frames before the final encoding. This is exactly where VisionCamera comes in: the library uses JSI (JavaScript Interface) instead of the classic asynchronous bridge, giving synchronous, near-zero-latency access to every frame directly from JavaScript, or more precisely from so-called worklets, before a photo or video is even produced from it.

The difference is conceptually decisive: while react-native-camera and expo-camera essentially treat the camera as a black box that ultimately produces finished files, VisionCamera turns every single frame into a first-class citizen of the API. That is the foundation for everything covered in the following sections, from simple photo capture to native frame processor plugins for compute-heavy image processing. For a modern React Native camera integration with a future-proof foundation, there is currently barely a way around VisionCamera.

2. Installation and setup

The first step of every VisionCamera integration is installing the package via npm install react-native-vision-camera, followed by pod install in the ios directory, since VisionCamera ships native modules for iOS and Android and is not a pure JavaScript package. On iOS, NSCameraUsageDescription and, if video recording with audio is planned, NSMicrophoneUsageDescription must also be added to Info.plist. Without these entries, the app crashes silently on the first camera access, a classic pitfall of camera integration.

On Android, the CAMERA permission and, analogous to iOS, RECORD_AUDIO must be declared in AndroidManifest.xml. VisionCamera also requires a minimum Android SDK version, currently a minSdkVersion of 26 is recommended so the newer Camera2 APIs are reliably available. Anyone maintaining an existing project with a lower minimum SDK version must raise it before integrating the library.

At runtime, the manifest entries alone are not enough, since both iOS and Android require explicit user consent from a certain version onward. The static methods Camera.requestCameraPermission() and Camera.requestMicrophonePermission() display the system permission dialog and return a result such as granted, denied, or not-determined. Only after the permission has been granted does the useCameraDevice hook return a usable camera device, which is why the permission check should always happen before rendering the actual Camera component.


# Install the VisionCamera package
npm install react-native-vision-camera

# iOS: install native pods (required, not a pure JS package)
cd ios && pod install && cd ..

# Android: no extra native step needed beyond a standard rebuild
npx react-native run-android

# Info.plist entries (ios/YourApp/Info.plist) - add manually
# <key>NSCameraUsageDescription</key>
# <string>This app uses the camera for photo and video capture</string>
# <key>NSMicrophoneUsageDescription</key>
# <string>This app uses the microphone for video recording with audio</string>

# AndroidManifest.xml entries (android/app/src/main/AndroidManifest.xml)
# <uses-permission android:name="android.permission.CAMERA" />
# <uses-permission android:name="android.permission.RECORD_AUDIO" />

3. The Camera component and device selection

At the center of every VisionCamera-based camera integration is the Camera component, which expects a concrete camera device as a prop. That device is determined via the hook useCameraDevice('back') or useCameraDevice('front'), which automatically selects the best available physical camera module on the given device, including ultra-wide or telephoto lenses on supported smartphones. Unlike older libraries, device selection does not need to be done manually through platform checks.

Resolution and frame rate are handled by the useCameraFormat hook, which picks the format, from all formats the device supports, that best matches the given requirements, for example a minimum resolution of 1920x1080 at at least 30 frames per second. This explicit format selection matters because modern smartphone cameras often offer dozens of formats with different resolutions, frame rates, and pixel formats, and picking the wrong one means either unnecessarily high computational load or image quality that is too low for the use case.

The isActive prop of the Camera component is an often underestimated but central detail: when set to false as soon as the user leaves the camera screen, for example when navigating to a different tab, VisionCamera fully pauses the camera sensor. That noticeably saves battery, reduces device heating, and is also relevant from a privacy standpoint, because the camera then actually stops capturing image data, even though the component itself may still be mounted in the React tree.


// CameraScreen.js — device selection, format, and capture
import { useRef, useState, useCallback } from 'react';
import { View, Pressable, Text, StyleSheet } from 'react-native';
import {
  Camera,
  useCameraDevice,
  useCameraFormat,
  useCameraPermission,
} from 'react-native-vision-camera';
import { useIsFocused } from '@react-navigation/native';

export function CameraScreen() {
  const camera = useRef(null);
  const isFocused = useIsFocused();
  const { hasPermission, requestPermission } = useCameraPermission();
  const [isRecording, setIsRecording] = useState(false);

  const device = useCameraDevice('back');
  const format = useCameraFormat(device, [
    { videoResolution: { width: 1920, height: 1080 } },
    { fps: 30 },
  ]);

  const takePhoto = useCallback(async () => {
    if (!camera.current) return;
    const photo = await camera.current.takePhoto({
      flash: 'auto',
      qualityPrioritization: 'balanced',
    });
    console.log('Photo saved at', photo.path);
  }, []);

  const startRecording = useCallback(() => {
    if (!camera.current) return;
    setIsRecording(true);
    camera.current.startRecording({
      onRecordingFinished: (video) => {
        setIsRecording(false);
        console.log('Video saved at', video.path);
      },
      onRecordingError: (error) => {
        setIsRecording(false);
        console.error('Recording failed', error);
      },
    });
  }, []);

  const stopRecording = useCallback(async () => {
    if (!camera.current) return;
    await camera.current.stopRecording();
  }, []);

  if (!hasPermission) {
    return (
      <Pressable onPress={requestPermission}>
        <Text>Grant camera permission</Text>
      </Pressable>
    );
  }
  if (device == null) return <Text>No camera device found</Text>;

  return (
    <View style={styles.container}>
      <Camera
        ref={camera}
        style={styles.camera}
        device={device}
        format={format}
        isActive={isFocused}
        photo={true}
        video={true}
        audio={true}
      />
      <Pressable onPress={isRecording ? stopRecording : startRecording}>
        <Text>{isRecording ? 'Stop' : 'Record'}</Text>
      </Pressable>
      <Pressable onPress={takePhoto}>
        <Text>Take photo</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
});

4. Photo and video capture

The method camera.takePhoto() is the central entry point for photo capture and accepts options such as flash (on, off, auto) as well as qualityPrioritization, which lets you trade off faster capture (speed) against higher image quality (quality). The returned object includes, among other things, path, width, height, and metadata such as orientation, but not a fully encoded image file in memory, only a file path pointing to a temporary storage location.

For video recording there is no single asynchronous call, but rather the pair startRecording() and stopRecording(). startRecording() expects callback functions such as onRecordingFinished and onRecordingError, because a video recording naturally runs over a longer period and its result cannot be returned as a simple promise. Inside onRecordingFinished, analogous to photos, a file path is then available through which the recorded video can be further processed.

An important practical point for camera integration: both photos and videos initially land in a temporary, app-internal directory that the operating system can clean up at any time. Anyone who wants to keep the capture permanently, for example for a later upload or a local gallery, must actively move or rename the file into a persistent directory, usually with a filesystem library such as react-native-fs. If this step is forgotten, captured photos and videos seemingly vanish without a trace after an app restart.

5. Frame processors and worklets

The real differentiator of VisionCamera compared to classic camera libraries is frame processors, enabled through the useFrameProcessor() hook. The passed callback is marked with the 'worklet' directive, a concept borrowed from the Reanimated world, which rebuilds the code at compile time so that it does not run on the regular JavaScript thread, but on its own dedicated background thread, wired directly through JSI.

This architectural decision is decisive for real-time applications: a camera sensor frequently delivers 30 or 60 frames per second, which corresponds to a time budget of 33 or 16 milliseconds per frame. If frame processing ran on the regular JS thread, it would compete with every other JavaScript execution, for example UI updates or network callbacks, and would inevitably lead to stutters and dropped frames. The separate worklet thread fully decouples frame processing from the rest of the app logic.

For more compute-heavy processing that is not needed on every single frame, for example object detection that reacts adequately even at 10 evaluations per second, VisionCamera offers the helper function runAtTargetFps(). It specifically throttles how often the enclosed code is actually executed, while the frame processor itself continues to be invoked at full camera speed. That prevents expensive computations from clogging the worklet thread and thereby indirectly causing the preview to stutter again.


// FrameProcessorScreen.js — worklet-based frame processor with throttling
import { StyleSheet } from 'react-native';
import { useCameraDevice, useFrameProcessor, useCodeScanner } from 'react-native-vision-camera';
import { runAtTargetFps } from 'react-native-vision-camera';
import { Camera } from 'react-native-vision-camera';

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

  const frameProcessor = useFrameProcessor((frame) => {
    'worklet';
    // Runs on a dedicated background thread via JSI, not the JS thread
    runAtTargetFps(5, () => {
      'worklet';
      // Expensive analysis throttled to 5 evaluations per second
      const brightness = estimateBrightness(frame);
      if (brightness < 40) {
        console.log('Low light detected, consider enabling flash');
      }
    });
  }, []);

  const codeScanner = useCodeScanner({
    codeTypes: ['qr', 'ean-13', 'code-128'],
    onCodeScanned: (codes) => {
      // Runs back on the JS thread — safe to update React state here
      if (codes.length > 0) {
        console.log('Scanned value:', codes[0].value);
      }
    },
  });

  if (device == null) return null;

  return (
    <Camera
      style={styles.camera}
      device={device}
      isActive={true}
      frameProcessor={frameProcessor}
      codeScanner={codeScanner}
    />
  );
}

const styles = StyleSheet.create({
  camera: { flex: 1 },
});

6. Barcode and QR code scanning

For the common use case of barcode and QR code scanning, there is no need to write a custom frame processor from scratch, since VisionCamera ships the ready-made hook useCodeScanner() for this purpose. The codeTypes array defines which formats should be recognized, for example qr for QR codes, ean-13 for classic product barcodes, or code-128 for shipping and ticket codes. The recognition itself runs internally through the native vision APIs of iOS and Android and does not need to be implemented manually.

The onCodeScanned callback, unlike a classic frame processor, runs back on the regular JS thread, which is why React state can be updated, navigation triggered, or a network request fired from there without restriction. In practice it pays off to additionally deduplicate recognized codes, for example via a short timer or a comparison with the last scanned value, since the same code would otherwise be reported multiple times within a few hundred milliseconds as long as it remains visible in the camera frame.

A typical real-world scenario for this kind of camera integration is a ticket scanner at an event entrance: the app scans the QR code on the digital ticket, checks the value against a booking API, and immediately shows visually whether entry is valid. Another example is a product check-in in a warehouse, where an EAN-13 barcode on a package is scanned and automatically linked to the matching storage location. In both cases the built-in code scanner fully replaces a separate, often more cumbersome scanning package.

7. Performance optimization

Choosing the right camera format via useCameraFormat is the first and most impactful lever for performance optimization. A 4K resolution at 60 frames per second produces significantly more data per second than 1080p at 30 frames per second, and every additional frame processor has to process that data volume. Anyone who only needs a QR code scanner should deliberately request a lower, but sufficient, format instead of reflexively picking the highest available resolution.

A second important lever is the camera's pixelFormat. VisionCamera supports, among others, yuv and rgb, where yuv is the native format of most camera sensors and can therefore be processed faster without additional conversion. Only when a frame processor actually needs RGB values per pixel, for example for certain image filters, is it worth explicitly requesting rgb, since that conversion itself costs processing time that would be unnecessary for pure code recognition.

It is also worth avoiding unnecessary re-renders of the Camera component, since every re-render potentially reconfigures the underlying native view. Frame processors and the code scanner should therefore be kept stable with useCallback or the equivalent memoization mechanisms of the hooks. It is also important to note: purely JavaScript-based frame processors hit a hard limit with genuinely complex image processing, for example neural networks for face detection, because even the fast worklet thread cannot match the raw performance of native image processing frameworks. For those cases, native frame processor plugins are the next logical step.

8. Native frame processor plugins

For compute-heavy tasks such as face detection with Apple's Vision framework or object detection with Google ML Kit, a plain JavaScript frame processor often is not enough, even when it runs on the separate worklet thread. The solution is a native frame processor plugin, written in Swift or Kotlin respectively, that processes the native frame buffer directly, without taking the detour through a JavaScript representation of the image data.

From the JavaScript side, such a plugin is registered via VisionCameraProxy.initFrameProcessorPlugin() and returns a callable function that can be used within a frame processor just like any regular function. Internally, VisionCamera again relies on JSI, so the call happens synchronously and without the serialization cost of the classic bridge. For the app developer, the native plugin therefore feels almost like an ordinary JavaScript function, even though native Swift or Kotlin code is working on the frame in the background.

The performance advantage of a native plugin lies in the fact that compute-heavy operations, such as running a neural network, execute directly in native code instead of being converted through additional intermediate steps into JavaScript objects. Especially for tasks that need to run in real time on every single frame, for example continuous face tracking, this difference is what tips the scale between a smooth and a noticeably stuttering camera preview.


// FaceDetectorPlugin.swift — minimal native Frame Processor Plugin (iOS)
import VisionCamera

@objc(FaceDetectorPlugin)
public class FaceDetectorPlugin: FrameProcessorPlugin {

  public override init(proxy: VisionCameraProxyHolder, options: [AnyHashable: Any]! = [:]) {
    super.init(proxy: proxy, options: options)
  }

  public override func callback(_ frame: Frame, withArguments arguments: [AnyHashable: Any]?) -> Any {
    // Access the underlying CMSampleBuffer for native processing
    let buffer = frame.buffer

    // Run face detection using Apple's Vision framework (simplified)
    let faceCount = detectFaces(in: buffer)

    return ["faceCount": faceCount]
  }
}

// FaceDetectorPlugin.kt — equivalent native Frame Processor Plugin (Android)
package com.mironsoft.app

import com.mrousavy.camera.frameprocessors.Frame
import com.mrousavy.camera.frameprocessors.FrameProcessorPlugin
import com.mrousavy.camera.frameprocessors.VisionCameraProxy

class FaceDetectorPlugin(proxy: VisionCameraProxy, options: Map<String, Any>?) :
  FrameProcessorPlugin() {

  override fun callback(frame: Frame, arguments: Map<String, Any>?): Any {
    // Access the underlying Image for native processing (e.g. via ML Kit)
    val image = frame.image
    val faceCount = detectFaces(image)

    return mapOf("faceCount" to faceCount)
  }
}

9. VisionCamera compared

The choice between the three common React Native camera libraries depends heavily on how deeply the planned camera integration needs to reach into the camera data. The following overview summarizes the key differences.

Library Frame processing Maintenance status Performance Setup complexity
react-native-camera Not supported Deprecated, unmaintained Bridge-based, slow Low
expo-camera Limited, no custom frame processing Actively maintained Solid for standard cases Very low
react-native-vision-camera Fully supported, JSI-based Actively maintained Native, JSI-synchronous Medium

In practice, expo-camera is recommended for simple use cases without real-time image processing, for example plain photo or video uploads within an Expo managed workflow. As soon as frame processors, custom-logic QR code scanning, or native image processing become part of the requirements, VisionCamera is the technically superior and future-proof choice for camera integration in React Native. react-native-camera should no longer be used in new projects.

Mironsoft

React Native development for iOS and Android

Camera integration that stays smooth even under real-time processing?

We set up VisionCamera for your React Native app, build frame processors, QR code scanning, and native plugins in Swift and Kotlin for performant real-time image processing.

VisionCamera setup

Device selection, formats, permissions, and photo/video capture cleanly configured

Frame processors

Worklet-based real-time processing including QR code and barcode scanning

Native plugins

Swift and Kotlin plugins for face detection and complex image processing

10. Summary

VisionCamera solves camera integration in React Native fundamentally differently from the outdated react-native-camera or the solid but limited expo-camera: through JSI and frame processors, the app gets direct, synchronous access to every single camera frame, not just to finished photos and videos. The basics are set up quickly, npm installation, native permissions in Info.plist and AndroidManifest.xml, device selection via useCameraDevice, and format selection via useCameraFormat.

Frame processors marked with the 'worklet' directive run on their own background thread and enable real-time processing at 30 or 60 frames per second without blocking the JS thread. The built-in useCodeScanner() hook covers barcode and QR code scanning without requiring custom recognition code. For truly compute-heavy tasks such as face detection, native frame processor plugins in Swift and Kotlin are the final building block that unlocks full native performance without giving up VisionCamera's unified JavaScript interface.

React Native Camera Integration with VisionCamera — Key Takeaways

Why VisionCamera

JSI instead of the classic bridge, synchronous access to every frame instead of only finished photos/videos.

Core functionality

useCameraDevice, useCameraFormat, takePhoto(), and startRecording()/stopRecording().

Frame processors

useFrameProcessor() with the 'worklet' directive runs on its own background thread via JSI.

Native plugins

Swift/Kotlin plugins via VisionCameraProxy.initFrameProcessorPlugin() for maximum performance.

11. FAQ: React Native Camera Integration with VisionCamera

1VisionCamera vs. expo-camera?
expo-camera covers standard photo/video without frame access. VisionCamera uses JSI and frame processors for synchronous access to every single frame.
2What is a worklet?
A function marked with 'worklet' that runs on its own background thread, wired directly through JSI, instead of on the regular JS thread.
3Why the 'worklet' directive?
Without it, the code would run on the JS thread and compete with UI updates. At 30 to 60 frames per second that causes stutters and dropped frames.
4Performance limits?
JS frame processors are not enough for very complex image processing like neural networks. Native frame processor plugins in Swift or Kotlin solve that.
5Which permissions are needed?
NSCameraUsageDescription/NSMicrophoneUsageDescription on iOS, CAMERA/RECORD_AUDIO on Android, each confirmed at runtime via Camera.requestCameraPermission().
6Which code types are supported?
Among others qr, ean-13, and code-128 via the codeTypes array, recognized through the native vision APIs of iOS and Android.
7How to write a native plugin?
In Swift or Kotlin, registered from JS via VisionCameraProxy.initFrameProcessorPlugin(), callable synchronously within a frame processor.
8Control flash and torch?
Flash via the flash option of takePhoto() (on/off/auto). A persistent torch via the Camera component's torch prop.
9Why does format selection matter?
useCameraFormat picks resolution/frame rate. An unnecessarily high resolution produces more data per frame and thus more load without added value.
10Why is react-native-camera outdated?
Officially unmaintained, no more updates for newer iOS/Android versions, increasing build problems. VisionCamera is the actively maintained alternative.