Peakiq Blog
Building a Reusable Vision Camera Component in React Native
Learn to build a versatile, ref-forwarded camera component in React Native using react-native-vision-camera for photo, video, and barcode scanning in one
Building a Reusable Vision Camera Component in React Native
react-native-vision-camera is the go-to camera library for React Native in 2025 — but wiring up photo capture, video recording, and barcode scanning in every screen gets repetitive fast.
This guide walks through building a single reusable VisionCamera component that handles all three, exposed via a clean ref API. Published on peakiq.in.
What We're Building
A single VisionCamera component that:
- Takes photos with configurable flash
- Records video with a start/stop ref API
- Optionally scans barcodes in real time
- Accepts an overlay slot for custom UI
- Clamps zoom between device min and max
- Handles missing camera devices gracefully
Dependencies
npm install react-native-vision-camera react-native-vision-camera-barcode-scannerMake sure you have camera and microphone permissions configured in both AndroidManifest.xml and Info.plist.
The Handle Interface
We expose three imperative methods via forwardRef so the parent screen can control the camera without prop drilling:
export interface VisionCameraHandle {
takePhoto: (options?: { flash?: 'on' | 'off' | 'auto' }) => Promise<string>;
recordAsync: () => Promise<{ uri: string; codec: string }>;
stopRecording: () => Promise<void>;
}takePhotoresolves with the file path of the captured imagerecordAsyncstarts recording and resolves with the video URI when stoppedstopRecordingsignals the recorder to finalize and save
Component Props
interface Props {
style?: StyleProp<ViewStyle>;
zoom?: number;
maxZoom?: number;
isActive?: boolean;
overlay?: React.ReactNode;
flash?: 'on' | 'off' | 'auto';
onMountError?: (error: Error) => void;
onCameraReady?: () => void;
onBarCodeRead?: (barcode: { value: string; type: string }) => void;
enableBarcode?: boolean;
enableVideo?: boolean;
}enableBarcode and enableVideo are opt-in — outputs are only registered when needed, which keeps the camera pipeline lean.
Full Implementation
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import { View, StyleProp, ViewStyle, Text, StyleSheet } from 'react-native';
import {
Camera,
useCameraDevice,
usePhotoOutput,
useVideoOutput,
} from 'react-native-vision-camera';
import { useBarcodeScannerOutput } from 'react-native-vision-camera-barcode-scanner';
export interface VisionCameraHandle {
takePhoto: (options?: { flash?: 'on' | 'off' | 'auto' }) => Promise<string>;
recordAsync: () => Promise<{ uri: string; codec: string }>;
stopRecording: () => Promise<void>;
}
interface Props {
style?: StyleProp<ViewStyle>;
zoom?: number;
maxZoom?: number;
isActive?: boolean;
overlay?: React.ReactNode;
flash?: 'on' | 'off' | 'auto';
onMountError?: (error: Error) => void;
onCameraReady?: () => void;
onBarCodeRead?: (barcode: { value: string; type: string }) => void;
enableBarcode?: boolean;
enableVideo?: boolean;
}
const VisionCamera = forwardRef<VisionCameraHandle, Props>(
(
{
style,
zoom = 1,
maxZoom,
isActive = true,
overlay,
flash = 'off',
onMountError,
onCameraReady,
onBarCodeRead,
enableBarcode = false,
enableVideo = false,
},
ref,
) => {
const device = useCameraDevice('back');
const photoOutput = usePhotoOutput();
const videoOutput = useVideoOutput({ enableAudio: enableVideo });
const recorderRef = useRef<any>(null);
const barcodeOutput = useBarcodeScannerOutput({
barcodeFormats: ['all-formats'],
onBarcodeScanned: (barcodes) => {
if (!enableBarcode) return;
const first = barcodes[0];
if (first) {
onBarCodeRead?.({
value: first.rawValue ?? '',
type: first.format ?? '',
});
}
},
onError: (error) => {
console.warn('Barcode scanner error', error);
},
});
useImperativeHandle(
ref,
() => ({
takePhoto: async (options) => {
const { filePath } = await photoOutput.capturePhotoToFile(
{ flashMode: options?.flash ?? flash },
{},
);
return filePath;
},
recordAsync: () => {
return new Promise(async (resolve, reject) => {
try {
const recorder = await videoOutput.createRecorder({});
recorderRef.current = recorder;
await recorder.startRecording(
(filePath, reason) => {
resolve({ uri: `file://${filePath}`, codec: 'mp4' });
},
(error) => {
reject(error);
},
);
} catch (e) {
reject(e);
}
});
},
stopRecording: async () => {
try {
await recorderRef.current?.stopRecording();
recorderRef.current = null;
} catch (error) {
console.info(error);
}
},
}),
[photoOutput, videoOutput, flash],
);
if (!device) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>No camera device found</Text>
</View>
);
}
const resolvedMaxZoom = maxZoom ?? device.maxZoom;
const clampedZoom = Math.min(Math.max(zoom, device.minZoom), resolvedMaxZoom);
const outputs = [
photoOutput,
...(enableVideo ? [videoOutput] : []),
...(enableBarcode ? [barcodeOutput] : []),
];
return (
<View style={{ flex: 1 }}>
<Camera
style={[StyleSheet.absoluteFill, style]}
device={device}
isActive={isActive}
zoom={clampedZoom}
outputs={outputs}
onStarted={onCameraReady}
onError={(error) => onMountError?.(new Error(error.message))}
/>
{overlay}
</View>
);
},
);
VisionCamera.displayName = 'VisionCamera';
export default VisionCamera;Key Design Decisions
Conditional outputs array
Outputs are only added to the camera pipeline when the corresponding feature is enabled:
const outputs = [
photoOutput,
...(enableVideo ? [videoOutput] : []),
...(enableBarcode ? [barcodeOutput] : []),
];This avoids unnecessary processing — a photo-only screen won't pay the cost of video or barcode initialization.
Zoom clamping
Zoom is clamped between the device's reported minZoom and maxZoom values, with an optional maxZoom prop override:
const resolvedMaxZoom = maxZoom ?? device.maxZoom;
const clampedZoom = Math.min(Math.max(zoom, device.minZoom), resolvedMaxZoom);Recorder ref
The active recorder instance is stored in a useRef so stopRecording can access it without closing over stale state:
const recorderRef = useRef<any>(null);Usage Example
import { useRef } from 'react';
import VisionCamera, { VisionCameraHandle } from './VisionCamera';
const ScannerScreen = () => {
const cameraRef = useRef<VisionCameraHandle>(null);
const handleCapture = async () => {
const path = await cameraRef.current?.takePhoto({ flash: 'auto' });
console.log('Photo saved at:', path);
};
return (
<VisionCamera
ref={cameraRef}
enableBarcode
onBarCodeRead={({ value, type }) => console.log(type, value)}
onCameraReady={() => console.log('Camera ready')}
overlay={<ScannerOverlay />}
/>
);
};What to Build Next
- Add front camera support by accepting a
cameraPositionprop ('back' | 'front') - Expose zoom gesture handling via
react-native-gesture-handler - Add a
captureModeprop to switch between photo and video mode declaratively - Integrate with a global media store to manage captured files
Published on peakiq.in — React Native guides, component patterns, and tutorials.