React Native
Camera Controls
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:
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
useCameraControls()
The hook owns the wiring between <VisionCamera> and your component state. It takes no
arguments.
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:
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.
Declarative Props
These props sit on <VisionCamera> and describe the camera you want. They are re-applied
natively as described in Control-prop persistence.
Zoom is wide-normalized
zoomRatio (and setZoom(), and the zoomRatio field on the state event) all use the same
scale on both platforms:
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
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.
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.
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).
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
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.
isPreviewActive is a first-frame signal
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.
The state is authoritative, not your request
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:
Correct - the label always reflects the camera:
Delivery semantics
onCameraStateChanged has a specific delivery contract. All of it is implemented in the React
Native bindings, not in the underlying native SDKs.
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.
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
Lens
Building a zoom switcher
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.
Two things to keep in mind:
- Measure the view you attach the touch handler to.
locationX/locationYare 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.
Pinning changes the zoom range, and it does so differently per platform
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/maxZoomRatioin 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/maxZoomRatiostay 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
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
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 whilecameraFacing="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
cameraFacingwhile 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.
Two consequences worth planning for:
- 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
onCameraStateChangedwill faithfully report those defaults during it. Do not treat a single event showingzoomRatio: 1.0right after a facing switch as the final answer, and do not "correct" it by re-issuingsetZoom()in response - that fights the re-assertion. Wait forisPreviewActiveto flip true again. - Values you set imperatively are re-asserted too.
setZoom()andsetTorch()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
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, notcamera.ref.current- the latter is alwaysundefined. - 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 checkcapabilities.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.
Related Documentation
- VisionCamera Component - the camera component itself
- Props Reference - the complete prop and ref-method table
- Camera Switching - the
cameraFacingprop - VisionCore Module - camera-independent operations