1. React Native
  2. Camera Controls

React Native

Camera Controls

INFO

Since v3.12.0: the Camera Controls API adds cross-platform zoom, torch, focus and lens selection to VisionCamera, plus a single camera-state event stream and a device capability snapshot. It also deprecates the older zoomLevel / enableFlash props and the toggleFlash() ref method.

The Camera Controls API gives you four things:

Piece What it is
useCameraControls() Hook that wires the camera ref, the state event, and the setters together
zoomRatio / torch / focusMode / pinnedLensId props Declarative control of the live camera
setZoom() / setTorch() / setFocusPoint() ref methods Imperative control for gesture-driven UI
onCameraStateChanged + VisionCore.getCameraCapabilities() What the camera is actually doing, and what the device can do

The design principle behind all of it: your requests are advisory, the state stream is authoritative. You ask for a zoom of 5.0, the device gives you what it can, and onCameraStateChanged tells you what actually happened. Everything below follows from that.


Quick Start

        import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { VisionCamera, useCameraControls } from 'react-native-vision-sdk';

export function ScannerScreen() {
  const camera = useCameraControls();
  const state = camera.state;

  return (
    <View style={styles.container}>
      <VisionCamera
        ref={camera.ref}
        onCameraStateChanged={camera.onCameraStateChanged}
        scanMode="barcode"
        style={styles.camera}
        onBarcodeDetected={(event) => {
          console.log('Barcodes:', event.codes);
        }}
      />

      {state && state.isPreviewActive && (
        <View style={styles.controls}>
          <Text style={styles.label}>{state.zoomRatio.toFixed(1)}x</Text>

          <Pressable onPress={() => camera.setZoom(1.0)}>
            <Text style={styles.label}>1x</Text>
          </Pressable>
          <Pressable onPress={() => camera.setZoom(2.0)}>
            <Text style={styles.label}>2x</Text>
          </Pressable>

          <Pressable onPress={() => camera.setTorch(!state.torchEnabled)}>
            <Text style={styles.label}>
              {state.torchEnabled ? 'Torch off' : 'Torch on'}
            </Text>
          </Pressable>
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
  controls: {
    position: 'absolute',
    bottom: 40,
    left: 20,
    right: 20,
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    backgroundColor: 'rgba(0, 0, 0, 0.7)',
    borderRadius: 12,
    padding: 16,
  },
  label: { color: 'white', fontSize: 16, fontWeight: '600' },
});

      

useCameraControls()

The hook owns the wiring between <VisionCamera> and your component state. It takes no arguments.

        import { useCameraControls } from 'react-native-vision-sdk';

const camera = useCameraControls();

      
Field Type Description
ref RefCallback<VisionCameraRefProps> Hand this to <VisionCamera ref={camera.ref} />. A callback ref, not a ref object.
cameraRef RefObject<VisionCameraRefProps | null> The real ref object, for imperative calls the hook does not wrap: capture(), start(), stop(), rescan(), pauseDetection(), resumeDetection().
onCameraStateChanged (event: VisionCameraStateEvent) => void Pass to <VisionCamera onCameraStateChanged={camera.onCameraStateChanged} />.
state VisionCameraStateEvent | undefined Latest camera state. undefined until the first event lands.
capabilities CameraCapabilities | undefined Device capability snapshot, fetched once per view instance. undefined while in flight, and permanently undefined if the fetch failed.
setZoom (ratio: number) => void Wide-normalized absolute zoom ratio.
setTorch (on: boolean) => void Torch on/off.
setFocusPoint (x: number, y: number) => void One-shot focus at a normalized point.
WARNING

camera.ref.current is always undefined. ref is a callback ref - a function React invokes on attach and detach. Callback refs have no .current. If you need imperative access beyond the three setters, use camera.cameraRef:

        // Correct
<VisionCamera ref={camera.ref} onCameraStateChanged={camera.onCameraStateChanged} />
camera.cameraRef.current?.capture();
camera.cameraRef.current?.rescan();

// Wrong - a compile error under TypeScript ("Property 'current' does not exist
// on type 'RefCallback<VisionCameraRefProps>'"), and a silent runtime no-op in
// untyped JavaScript
camera.ref.current?.capture();

      

The hook needs a callback ref because that is the only way it can observe attach/detach transitions, which is what lets it reset state and capabilities when the view instance changes.

State resets on view-instance change

When the underlying view instance changes - a keyed remount, a conditional render that swaps the camera out and back - the hook resets both state and capabilities to undefined, so a fresh view never briefly renders its predecessor's values. state repopulates almost immediately from the replay event (see Delivery semantics), and capabilities is re-fetched.

The hook also discards a getCameraCapabilities() response that resolves after a newer view has attached, so a slow fetch for a detached view can never overwrite the current one's capabilities.

Handling missing capabilities

capabilities is best-effort. A failed fetch logs a warning and leaves it undefined - it does not throw and does not disable the camera. Always guard, and fall back to the live zoom range from state, which is always available once the camera is running.

        const camera = useCameraControls();
const facing = camera.state?.facing ?? 'back';

// Prefer discrete device stops; fall back to the live range from state.
const stops = camera.capabilities?.zoomStops[facing];
const min = camera.state?.minZoomRatio ?? 1.0;
const max = camera.state?.maxZoomRatio ?? 1.0;

      

Declarative Props

These props sit on <VisionCamera> and describe the camera you want. They are re-applied natively as described in Control-prop persistence.

Prop Type Default Description
zoomRatio number 1.0 Canonical zoom control. Wide-normalized absolute ratio.
torch boolean false Canonical torch control.
focusMode 'continuous' | 'single' | 'locked' 'continuous' 'continuous' is AF-C, 'single' is AF-S, 'locked' fixes focus at its current position.
pinnedLensId string undefined Pin a specific physical lens by id. undefined means Auto (the OS picks the lens per zoom). See Lens pinning.
onCameraStateChanged (event: VisionCameraStateEvent) => void - The camera state stream. See The camera state event.

Zoom is wide-normalized

zoomRatio (and setZoom(), and the zoomRatio field on the state event) all use the same scale on both platforms:

Value Meaning
0.5 Ultra-wide territory
1.0 The wide lens at 1x
3.0 Telephoto territory

This is not the platform-native zoom factor. On iOS it is not videoZoomFactor. The SDK converts in both directions, so a ratio means the same thing on an iPhone and on a Pixel.

Values outside the device's supported range are clamped by the device, not rejected. See state is authoritative.

Deprecated props and methods

Deprecated Use instead Notes
zoomLevel prop zoomRatio prop Same native path. If both are set, zoomRatio wins and a one-time development warning fires.
enableFlash prop torch prop Same native path. If both are set, torch wins and a one-time development warning fires.
toggleFlash(enabled) ref method setTorch(enabled) ref method toggleFlash() still works and now routes through the same canonical path.
NOTE

The deprecated props still function - nothing breaks by leaving them in place. Migrate when convenient, but do not set both members of a pair: the collision detection is a best-effort heuristic that can under-warn, so a stale zoomLevel sitting next to a new zoomRatio may silently do nothing without telling you.


Imperative Methods

Available on VisionCameraRefProps (so on camera.cameraRef.current, or on your own useRef<VisionCameraRefProps>), and the first three are re-exported by the hook.

Method Signature Description
setZoom (level: number) => void Set the wide-normalized zoom ratio. Fire-and-forget.
setTorch (enabled: boolean) => void Turn the torch on or off. Fire-and-forget - the result lands in the next event's torchEnabled.
setFocusPoint (x: number, y: number) => void Trigger a one-shot focus and metering pass at a normalized point (0-1, top-left origin). Does not change focusMode.

Use the imperative methods for gesture-driven controls - a zoom slider's drag handler, a tap-to-focus overlay - where routing every frame through React state would be wasteful. Use the props for values that are part of your component state.

WARNING

setFocusPoint() is one-shot on both platforms. It requests a single focus and metering pass and is then consumed. It is deliberately not tracked or re-applied when the camera session is rebuilt, so a focus point does not survive a stop/start, a background/foreground cycle, or a facing change. Only focusMode is persistent. If you want a tap-to-focus point to hold, re-issue it after the camera comes back up (watch for isPreviewActive flipping true again).

NOTE

setFocusSettings() is a different, older method that configures the focus image overlay and bounding-box styling. It is unrelated to focusMode and setFocusPoint(). See Props Reference.


The Camera State Event

onCameraStateChanged is the single source of truth for what the camera is doing. There is no separate onCameraReady event - everything is folded into this one stream.

VisionCameraStateEvent

Field Type Description
status CameraStatus 'idle' | 'starting' | 'running' | 'interrupted' | 'error'
errorCode CameraErrorCode | undefined Fatal error code. Only set when status === 'error'.
errorMessage string | undefined Human-readable message for errorCode.
warningCode CameraErrorCode | undefined Non-fatal warning code. The camera keeps running.
warningMessage string | undefined Human-readable message for warningCode.
facing 'back' | 'front' Which physical camera position is currently active.
activeLensId string | undefined Id of the lens currently in use, matching an id from getCameraCapabilities().
zoomRatio number Current wide-normalized zoom ratio.
minZoomRatio number Minimum ratio the active lens/facing supports right now.
maxZoomRatio number Maximum ratio the active lens/facing supports right now.
torchEnabled boolean Whether the torch is currently on.
focusMode FocusMode Currently active focus mode.
isPreviewActive boolean Whether the current camera binding has delivered at least one frame. See below.

CameraErrorCode is one of 'permission-denied', 'lens-unavailable', or 'configuration-failed', and the same union is used for both errorCode and warningCode. The difference is severity, not vocabulary: errorCode accompanies status === 'error' and the camera is down; warningCode means something you asked for could not be honoured but the camera is still running.

        <VisionCamera
  ref={camera.ref}
  onCameraStateChanged={(event) => {
    camera.onCameraStateChanged(event);

    if (event.status === 'error') {
      console.error('Camera down:', event.errorCode, event.errorMessage);
    } else if (event.warningCode) {
      console.warn('Camera warning:', event.warningCode, event.warningMessage);
    }
  }}
/>

      

isPreviewActive is a first-frame signal

WARNING

isPreviewActive does not mean "the session started". It means "the currently bound camera has delivered at least one frame." This is the signal to reveal your preview.

It is false initially, false while idle or starting, and it resets to false on every rebind - a facing switch, a lens-pin change, stop(), an interruption (backgrounding), or an error - staying false until that binding's own first frame lands. A state where status === 'running' but isPreviewActive === false is normal and expected: the session is up, the pixels are not there yet.

status === 'running' && isPreviewActive === true is the condition to gate a placeholder or splash on. Waiting on status === 'running' alone shows the user a black rectangle.

        import React from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { VisionCamera, useCameraControls } from 'react-native-vision-sdk';

export function GatedPreview() {
  const camera = useCameraControls();
  const state = camera.state;
  const isLive = state?.status === 'running' && state.isPreviewActive;

  return (
    <View style={styles.container}>
      <VisionCamera
        ref={camera.ref}
        onCameraStateChanged={camera.onCameraStateChanged}
        scanMode="barcode"
        style={styles.camera}
      />
      {!isLive && (
        <View style={styles.placeholder}>
          <ActivityIndicator color="white" />
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
  placeholder: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: 'black',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

      

The state is authoritative, not your request

WARNING

Never assume your request was honoured. Read the result back from state.

setZoom(5.0) on a device whose maximum is 4.2 produces zoomRatio: 4.2 in the next event. The hook is a pure passthrough - it never echoes your request back to you, and state never contains a value the camera did not actually reach.

The same applies to setTorch() (a device with no torch on the active facing stays torchEnabled: false) and to pinnedLensId (an unpinnable id silently resolves to Auto).

Drive your UI from state, not from a local copy of what you asked for. If you keep a local slider value for responsiveness, reconcile it against state.zoomRatio when events arrive.

Wrong - the label lies whenever the device clamps the request:

        const [zoom, setZoom] = useState(1.0);

const bumpZoom = () => {
  const next = zoom + 1;
  setZoom(next);          // local state now disagrees with the camera
  camera.setZoom(next);
};

// Renders "5.0x" on a device that only reached 4.2x
<Text>{zoom.toFixed(1)}x</Text>

      

Correct - the label always reflects the camera:

        const bumpZoom = () => {
  const current = camera.state?.zoomRatio ?? 1.0;
  const max = camera.state?.maxZoomRatio ?? 1.0;
  camera.setZoom(Math.min(current + 1, max));
};

<Text>{(camera.state?.zoomRatio ?? 1.0).toFixed(1)}x</Text>

      

Delivery semantics

onCameraStateChanged has a specific delivery contract. All of it is implemented in the React Native bindings, not in the underlying native SDKs.

Behaviour Detail
Replay on attach The event fires once per native view instance, carrying the current state, so useCameraControls().state is never stale-undefined on an already-running camera. Strictly speaking it is keyed to native view creation, not to the handler attaching - iOS emits it during camera setup, Android on the view's first prop commit. In practice the two are indistinguishable, because VisionCamera binds a stable handler at view creation.
Throttled to about 10 Hz Rapid state changes are coalesced to at most one event every 100 ms.
Transitions bypass the throttle A change to status, a fatal errorCode, or a non-fatal warningCode is delivered immediately regardless of the throttle window.
Trailing edge guaranteed If a burst of changes ends inside a throttle window, the latest dropped state is still delivered at the window boundary. You never get stuck on a stale value.
The lens-pin warning is one-shot An unresolvable pinnedLensId is reported by the RN bindings as a warningCode: 'lens-unavailable' that rides on exactly one event and is then cleared. Handle it when you see it - it will not repeat. Warnings originating from the native session instead ride along on every state snapshot for as long as the condition holds.
Optional strings are normalized The iOS native layer delivers absent optional strings as ''; the RN layer converts those to undefined so errorCode, errorMessage, warningCode, warningMessage and activeLensId have identical shapes on both platforms.
NOTE

The throttle is a React Native binding behaviour. Neither native SDK time-throttles its camera-state callbacks - natively, one state transition is one callback. The React Native layer adds the 100 ms coalescing window on both platforms so a busy camera cannot flood the JS bridge.

The practical consequence: onCameraStateChanged coalesces rapid native transitions. If your app depends on observing every intermediate value of a fast-moving field (a continuous zoom ramp, for instance), you will see the endpoints and some samples in between, not every step. Status, error and warning transitions are exempt and are never dropped.


Device Capabilities

VisionCore.getCameraCapabilities() returns a static snapshot of what the device can do. It is camera-independent: no mounted VisionCamera, no API key, and no initialization are required.

        import { VisionCore } from 'react-native-vision-sdk';
import type { CameraCapabilities } from 'react-native-vision-sdk';

const capabilities: CameraCapabilities = await VisionCore.getCameraCapabilities();

      

useCameraControls() calls this for you and exposes the result as camera.capabilities. Call it directly only when you need capabilities before mounting a camera - for example, to decide whether to show a torch button at all.

CameraCapabilities

Field Type Description
lenses Lens[] Every lens on the device, across both facings.
zoomStops Record<'back' | 'front', number[]> Meaningful discrete zoom ratios per facing - the values a native camera app would put on its 0.5x / 1x / 3x switcher.
hasTorch Record<'back' | 'front', boolean> Whether a torch is available per facing.
supportsFocusPoint Record<'back' | 'front', boolean> Whether tap-to-focus is supported per facing.

Lens

Field Type Description
id string Stable identifier. This is what you pass to pinnedLensId.
kind 'ultraWide' | 'wide' | 'telephoto' | 'unknown' Physical lens kind. Note the camelCase spelling of 'ultraWide'.
facing 'back' | 'front' Which camera position this lens belongs to.
minZoomRatio number Minimum wide-normalized ratio for this lens.
maxZoomRatio number Maximum wide-normalized ratio for this lens.
zoomSwitchPoints number[] Ratios at which the OS switches into or out of this lens. Populated for logical lenses only.
hasFlash boolean Whether this lens has an attached flash/torch unit.
isLogical boolean true if this is a multi-camera fusion lens rather than a single physical sensor.
isPinnable boolean true if this lens can be targeted by pinnedLensId. Check this before pinning.

Building a zoom switcher

        import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { VisionCamera, useCameraControls } from 'react-native-vision-sdk';

export function ZoomSwitcher() {
  const camera = useCameraControls();
  const state = camera.state;
  const facing = state?.facing ?? 'back';
  const stops = camera.capabilities?.zoomStops[facing] ?? [1.0];
  const hasTorch = camera.capabilities?.hasTorch[facing] ?? false;

  return (
    <View style={styles.container}>
      <VisionCamera
        ref={camera.ref}
        onCameraStateChanged={camera.onCameraStateChanged}
        scanMode="barcode"
        style={styles.camera}
      />

      <View style={styles.bar}>
        {stops.map((stop) => {
          const active = state !== undefined && Math.abs(state.zoomRatio - stop) < 0.05;
          return (
            <Pressable
              key={stop}
              style={[styles.stop, active && styles.stopActive]}
              onPress={() => camera.setZoom(stop)}
            >
              <Text style={styles.stopLabel}>{stop.toFixed(1)}x</Text>
            </Pressable>
          );
        })}

        {hasTorch && (
          <Pressable
            style={styles.stop}
            onPress={() => camera.setTorch(!(state?.torchEnabled ?? false))}
          >
            <Text style={styles.stopLabel}>
              {state?.torchEnabled ? 'Off' : 'On'}
            </Text>
          </Pressable>
        )}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
  bar: {
    position: 'absolute',
    bottom: 40,
    alignSelf: 'center',
    flexDirection: 'row',
    backgroundColor: 'rgba(0, 0, 0, 0.6)',
    borderRadius: 24,
    padding: 6,
  },
  stop: {
    paddingHorizontal: 14,
    paddingVertical: 8,
    borderRadius: 18,
  },
  stopActive: { backgroundColor: 'rgba(255, 255, 255, 0.25)' },
  stopLabel: { color: 'white', fontSize: 14, fontWeight: '600' },
});

      
TIP

Prefer zoomStops over an arbitrary ladder like [1, 2, 3, 4, 5]. The stops are derived from the device's real lens switch-over points, so tapping one lands on a native lens boundary rather than a digitally cropped intermediate.


Tap to Focus

setFocusPoint(x, y) takes normalized coordinates in the range 0-1 with a top-left origin, so you have to divide the touch position by the preview's measured size.

        import React, { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import type { LayoutChangeEvent, GestureResponderEvent } from 'react-native';
import { VisionCamera, useCameraControls } from 'react-native-vision-sdk';

export function TapToFocusCamera() {
  const camera = useCameraControls();
  const [size, setSize] = useState({ width: 0, height: 0 });

  const facing = camera.state?.facing ?? 'back';
  const canFocus = camera.capabilities?.supportsFocusPoint[facing] ?? false;

  const onLayout = (event: LayoutChangeEvent) => {
    const { width, height } = event.nativeEvent.layout;
    setSize({ width, height });
  };

  const onTouchEnd = (event: GestureResponderEvent) => {
    if (!canFocus || size.width === 0 || size.height === 0) {
      return;
    }
    const { locationX, locationY } = event.nativeEvent;
    camera.setFocusPoint(
      Math.min(Math.max(locationX / size.width, 0), 1),
      Math.min(Math.max(locationY / size.height, 0), 1)
    );
  };

  return (
    <View style={styles.container} onLayout={onLayout}>
      <VisionCamera
        ref={camera.ref}
        onCameraStateChanged={camera.onCameraStateChanged}
        scanMode="barcode"
        focusMode="continuous"
        style={styles.camera}
      />
      <View style={StyleSheet.absoluteFill} onTouchEnd={onTouchEnd} />
    </View>
  );
}

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

      

Two things to keep in mind:

  • Measure the view you attach the touch handler to. locationX / locationY are relative to the responder view, so the overlay must cover exactly the same rectangle as the camera.
  • Check capabilities.supportsFocusPoint[facing] first. setFocusPoint() on a facing that does not support it is a silent no-op.

setFocusPoint() does not change focusMode. Under 'continuous' the camera will drift back to continuous autofocus after the one-shot pass. If you want the tapped point to stick, set focusMode="locked" after the pass, and remember that focusMode persists across a rebind while the focus point does not.


Lens Pinning

By default the OS picks which physical lens to use based on the current zoom - that is Auto, and it is what you get when pinnedLensId is undefined. Setting pinnedLensId restricts the session to one specific physical lens.

        import React, { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { VisionCamera, useCameraControls } from 'react-native-vision-sdk';

export function LensPicker() {
  const camera = useCameraControls();
  const [pinnedLensId, setPinnedLensId] = useState<string | undefined>(undefined);

  const backLenses = (camera.capabilities?.lenses ?? []).filter(
    (lens) => lens.facing === 'back' && lens.isPinnable
  );

  return (
    <View style={styles.container}>
      <VisionCamera
        ref={camera.ref}
        onCameraStateChanged={camera.onCameraStateChanged}
        scanMode="barcode"
        pinnedLensId={pinnedLensId}
        style={styles.camera}
      />

      <View style={styles.bar}>
        <Pressable style={styles.item} onPress={() => setPinnedLensId(undefined)}>
          <Text style={styles.label}>Auto</Text>
        </Pressable>

        {backLenses.map((lens) => (
          <Pressable
            key={lens.id}
            style={styles.item}
            onPress={() => setPinnedLensId(lens.id)}
          >
            <Text style={styles.label}>{lens.kind}</Text>
          </Pressable>
        ))}
      </View>

      <Text style={styles.status}>
        Active lens: {camera.state?.activeLensId ?? 'unknown'}
        {'  '}
        Range: {(camera.state?.minZoomRatio ?? 1).toFixed(2)}-
        {(camera.state?.maxZoomRatio ?? 1).toFixed(2)}x
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  camera: { flex: 1 },
  bar: {
    position: 'absolute',
    bottom: 80,
    alignSelf: 'center',
    flexDirection: 'row',
    backgroundColor: 'rgba(0, 0, 0, 0.6)',
    borderRadius: 24,
    padding: 6,
  },
  item: { paddingHorizontal: 14, paddingVertical: 8 },
  label: { color: 'white', fontSize: 14, fontWeight: '600' },
  status: {
    position: 'absolute',
    bottom: 40,
    alignSelf: 'center',
    color: 'white',
    fontSize: 12,
  },
});

      

Pinning changes the zoom range, and it does so differently per platform

WARNING

A pin restricts the session to one physical lens, and the zoom range you get back is not the same on both platforms.

  • iOS binds the pinned physical device directly, so the live minZoomRatio / maxZoomRatio in the state event collapse to that lens's own range. Pinning the wide lens therefore disables zooming out to ultra-wide: the ratios below 1.0 simply stop being reachable.
  • Android reports the full logical range regardless of a physical pin, so minZoomRatio / maxZoomRatio stay at the device's overall range even while pinned, and a zoom request outside the pinned lens's own range is accepted rather than rejected.

Because you write one codebase for both, do not build UI on the assumption that the range narrows. Read state.minZoomRatio / state.maxZoomRatio on every event and let the slider or ladder follow whatever the platform reports.

There is a related corollary: Lens.minZoomRatio / Lens.maxZoomRatio from getCameraCapabilities() describe the lens in isolation and can disagree with the live state.minZoomRatio / state.maxZoomRatio while pinned. The live values from state are the ones the camera will actually honour.

An unresolvable pin never throws

NOTE

pinnedLensId is a plain string, and the React Native bindings resolve it against the device's lens list. When the id is unknown, or names a lens whose isPinnable is false, the bindings log a warning, fall back to Auto, and surface warningCode: 'lens-unavailable' with warningMessage on the next onCameraStateChanged event. Nothing throws into JS and the camera keeps running.

This is React Native binding behaviour layered on top of a native API that does throw: both native SDKs reject an unpinnable lens at construction time. The bindings resolve the id and absorb that failure so a bad string cannot crash your app. Filter on isPinnable when you build a lens picker, and watch warningCode to detect a pin that did not take.

Pinning and cameraFacing

WARNING

Do not drive cameraFacing and pinnedLensId at the same time. A pin is facing-specific, so the two props can contradict each other, and they are resolved differently on each platform.

  • A resolved pin wins over cameraFacing. Pinning a front-facing lens while cameraFacing="back" moves the camera to the front - the bindings bring the facing in line with whichever facing the pinned lens actually belongs to. This is deliberate, and it works regardless of the order the two props arrive in.
  • Changing cameraFacing while a pin is set is not reliable across platforms. Android treats a genuine facing switch as a reason to drop the lens selection back to Auto; iOS re-resolves the pin and returns to the pinned lens's facing.

Pick one control. To move to the other camera while pinning, set pinnedLensId to a lens on that facing and leave cameraFacing alone. Then read state.facing and state.activeLensId back rather than assuming either prop won.

See Camera Switching for the cameraFacing prop on its own.


Control-prop Persistence

The native camera session is torn down and rebuilt more often than you might expect: on stop() / start(), on a view recreation, on returning from the background, and on a facing or lens change. The underlying SDKs reset runtime camera settings on those rebuilds, so the React Native bindings re-assert your declared zoomRatio, torch, focusMode and pinnedLensId afterwards - you do not have to.

Event zoomRatio / torch / focusMode pinnedLensId Focus point from setFocusPoint()
stop() then start() Re-asserted Re-asserted Lost
Background then foreground Re-asserted Re-asserted Lost
View recreation / remount Re-asserted Re-asserted Lost
cameraFacing change Re-asserted Platform-dependent - see above Lost

Two consequences worth planning for:

  1. Re-assertion is not instantaneous. There is a short window after a rebuild where the camera is running with SDK defaults (zoom 1.0, torch off, continuous focus) before your values land, and onCameraStateChanged will faithfully report those defaults during it. Do not treat a single event showing zoomRatio: 1.0 right after a facing switch as the final answer, and do not "correct" it by re-issuing setZoom() in response - that fights the re-assertion. Wait for isPreviewActive to flip true again.
  2. Values you set imperatively are re-asserted too. setZoom() and setTorch() update the same tracked state as the props, so a value set through a command survives a rebuild exactly like a value set through a prop.

Platform Notes

Behaviour iOS Android
Zoom / torch / focus mode / lens pinning Supported Supported
Zoom scale Wide-normalized, identical to Android Wide-normalized, identical to iOS
Zoom range while pinned Collapses to the pinned lens's range Stays at the full logical range
setFocusPoint() One-shot One-shot
State event throttle About 10 Hz, added by the RN bindings About 10 Hz, added by the RN bindings
Pin dropped on a cameraFacing change No - the pin is re-resolved Yes - the selection returns to Auto

Troubleshooting

camera.state stays undefined

  • Confirm you passed onCameraStateChanged={camera.onCameraStateChanged} to <VisionCamera>. The hook cannot receive events it was not wired to.
  • Confirm you passed ref={camera.ref}. Without it the hook never observes the view attaching, so it never fetches capabilities either.

Zoom, torch or focus commands do nothing

  • Verify you are calling through camera.cameraRef.current, not camera.ref.current - the latter is always undefined.
  • If you are using your own useRef, the ref must be attached to a mounted <VisionCamera>; commands issued before mount are dropped.
  • Check the value you asked for against state.minZoomRatio / state.maxZoomRatio, and check capabilities.hasTorch[facing] before blaming the torch call.

The preview is black even though status is 'running'

Expected until the first frame lands. Gate your placeholder on status === 'running' && isPreviewActive === true.

A pinned lens is not being used

Read state.activeLensId and state.warningCode. A warningCode of 'lens-unavailable' means the id was unknown or not pinnable and the camera fell back to Auto. Filter your lens list on isPinnable, and remember the warning is one-shot - if you missed it, remount and watch again.

Zoom or torch reverts to its default on its own

Something rebuilt the camera session - a facing change, a background/foreground cycle, or a remount. Your declared values are re-asserted shortly afterwards. If they never come back, verify you are setting the canonical zoomRatio / torch props rather than only calling the imperative setters from an effect that no longer runs.