The Camera

Render a live preview with CameraView from expo-camera, take a photo through a ref, switch lenses, and free the hardware on leaving.

Syntax<CameraView ref={ref} style={styles.preview} facing="back" active />

The system camera UI is enough for one quick photo. When you need your own shutter button, an overlay, a barcode reader or continuous capture, you render the preview yourself with expo-camera.

CameraView and permission

CameraView is the component. It is not an image: it is a live native preview surface placed in your layout, so it needs a style with real size, usually flex: 1. Older code importing a Camera component comes from the removed legacy API.

useCameraPermissions() returns a tuple: [permission, requestPermission]. permission is null while the status is still being read, then an object with granted, status and canAskAgain. Render all three states. Keep the hook call above any early return, because hooks must run in the same order every render.

Taking a photo

Capture is a method on the component instance, not a prop. Attach a ref typed useRef<CameraView>(null) and call takePictureAsync({ quality, base64, exif, skipProcessing }). It resolves with { uri, width, height }, writing the file to the app's cache directory.

facing takes 'back' or 'front'; mode takes 'picture' or 'video'; flash takes 'off', 'on', 'auto' or 'screen'.

Giving the hardware back

Only one preview can be active at a time. Set active={false} to release it on iOS, and unmount CameraView entirely when the screen loses focus, which works on both platforms.

Example

Example · tsx
import { useRef, useState } from "react";
import { CameraView, useCameraPermissions, type CameraType } from "expo-camera";
import { Button, Image, StyleSheet, Text, View } from "react-native";

export default function CameraScreen() {
  const [permission, requestPermission] = useCameraPermissions();
  const [facing, setFacing] = useState<CameraType>("back");
  const [photoUri, setPhotoUri] = useState<string | null>(null);
  const cameraRef = useRef<CameraView>(null);

  async function takePhoto() {
    const photo = await cameraRef.current?.takePictureAsync({ quality: 0.6 });
    if (photo) setPhotoUri(photo.uri);
  }

  if (!permission) return <View style={styles.screen} />; // status not read yet

  if (!permission.granted) {
    return (
      <View style={styles.screen}>
        <Text>Camera access is needed to take a photo.</Text>
        <Button title="Allow camera" onPress={requestPermission} />
      </View>
    );
  }

  return (
    <View style={styles.screen}>
      {/* `active` alone means active={true} */}
      <CameraView ref={cameraRef} style={styles.preview} facing={facing} active />
      <Button title="Take photo" onPress={takePhoto} />
      <Button
        title="Flip"
        onPress={() => setFacing((f) => (f === "back" ? "front" : "back"))}
      />
      {photoUri ? <Image source={{ uri: photoUri }} style={styles.thumb} /> : null}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, gap: 12, justifyContent: "center", padding: 16 },
  preview: { flex: 1, borderRadius: 12 },
  thumb: { width: 72, height: 72, alignSelf: "center" },
});

When to use it

  • A custom capture screen with your own shutter button and framing overlay.
  • Scanning a QR code to join a group or redeem a ticket.
  • Recording a 30-second clip inside the app rather than in the system camera.
  • Switching to the front lens for a guided identity photo.

More examples

Releasing the camera when the screen is not focused

A tab or stack screen stays mounted after you navigate away, so its camera keeps running: the battery drains, the recording indicator stays lit, and any other preview in the app opens black. useIsFocused re-renders the screen on focus changes, and the conditional render unmounts CameraView when the screen is in the background. Use this on every screen that holds a preview, not only the obvious ones.

Example · tsx
import { useIsFocused } from "@react-navigation/native";
import { CameraView, useCameraPermissions } from "expo-camera";
import { StyleSheet, Text, View } from "react-native";

export default function CaptureTab() {
  const [permission] = useCameraPermissions();
  const isFocused = useIsFocused(); // true only while this screen is on top

  if (!permission?.granted) {
    return (
      <View style={styles.screen}>
        <Text>No camera access.</Text>
      </View>
    );
  }

  return (
    <View style={styles.screen}>
      {/* Unmounting frees the hardware on both platforms.
          The `active` prop only releases it on iOS. */}
      {isFocused ? <CameraView style={styles.preview} facing="back" /> : null}
    </View>
  );
}

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

Reading a QR code from the preview

Barcode scanning is a prop on the same preview, not a separate module: list the formats you accept in barcodeScannerSettings and handle onBarcodeScanned. The callback fires on every frame that contains a code, which is the usual bug here, so the handler is replaced with undefined after the first hit and restored when the user asks to scan again. Narrow barcodeTypes to what you actually accept; fewer formats means faster detection.

Example · tsx
import { useState } from "react";
import { CameraView, useCameraPermissions } from "expo-camera";
import { Button, StyleSheet, Text, View } from "react-native";

export default function QrScanner() {
  const [permission, requestPermission] = useCameraPermissions();
  const [code, setCode] = useState<string | null>(null);

  if (!permission?.granted) {
    return <Button title="Allow camera" onPress={requestPermission} />;
  }

  return (
    <View style={styles.screen}>
      <CameraView
        style={styles.preview}
        facing="back"
        barcodeScannerSettings={{ barcodeTypes: ["qr", "ean13"] }}
        // Fires repeatedly while a code is in frame, so detach it once handled.
        onBarcodeScanned={code ? undefined : ({ data }) => setCode(data)}
      />
      <Text style={styles.label}>{code ?? "Point the camera at a code"}</Text>
      {code ? <Button title="Scan another" onPress={() => setCode(null)} /> : null}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1 },
  preview: { flex: 1 },
  label: { padding: 16, textAlign: "center" },
});

Recording a short video clip

Video needs mode="video" on the preview and the microphone permission as well as the camera one. The shape that surprises people is recordAsync: the promise does not resolve when recording starts, it resolves with { uri } when stopRecording() is called or maxDuration is hit, so the stop button talks to the ref while the awaited call is still pending. Set maxDuration on anything a user might leave running.

Example · tsx
import { useRef, useState } from "react";
import { CameraView, useCameraPermissions, useMicrophonePermissions } from "expo-camera";
import { Button, StyleSheet, Text, View } from "react-native";

export default function Recorder() {
  const [camera] = useCameraPermissions();
  const [mic, requestMic] = useMicrophonePermissions();
  const ref = useRef<CameraView>(null);
  const [recording, setRecording] = useState(false);
  const [clip, setClip] = useState<string | null>(null);

  async function start() {
    if (!mic?.granted) await requestMic(); // audio is its own permission
    setRecording(true);
    // recordAsync resolves only after stopRecording() or a limit is reached.
    const video = await ref.current?.recordAsync({ maxDuration: 30 });
    setRecording(false);
    if (video) setClip(video.uri);
  }

  if (!camera?.granted) return <Text>No camera access.</Text>;

  return (
    <View style={styles.screen}>
      <CameraView ref={ref} style={styles.preview} mode="video" facing="back" />
      <Button
        title={recording ? "Stop" : "Record"}
        onPress={() => (recording ? ref.current?.stopRecording() : start())}
      />
      {clip ? <Text style={styles.path}>Saved: {clip}</Text> : null}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, gap: 12 },
  preview: { flex: 1 },
  path: { padding: 12 },
});

Discussion

  • Be the first to comment on this lesson.