1. Capturing Photos with expo-camera
npx expo install expo-camera
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRef } from 'react';
function CameraScreen() {
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
if (!permission) return null; // still loading
if (!permission.granted) {
return (
<View>
<Text>We need your permission to use the camera.</Text>
<Pressable onPress={requestPermission}><Text>Grant permission</Text></Pressable>
</View>
);
}
async function takePhoto() {
const photo = await cameraRef.current?.takePictureAsync({ quality: 0.7 });
console.log(photo?.uri); // a local file:// URI
}
return (
<CameraView ref={cameraRef} style={{ flex: 1 }} facing="back">
<Pressable onPress={takePhoto} style={{ alignSelf: 'center', marginBottom: 24 }}>
<Text>Capture</Text>
</Pressable>
</CameraView>
);
}
useCameraPermissions returns null while the permission
status is still loading — rendering nothing (or a spinner) for that one frame
avoids a flash of the "please grant permission" UI for users who already granted it.
2. Picking Existing Media
Not every photo needs to be taken fresh — expo-image-picker opens the
native photo library (or camera, with the same API) and hands back a local URI.
npx expo install expo-image-picker
import * as ImagePicker from 'expo-image-picker';
async function pickImage() {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.7,
});
if (!result.canceled) {
return result.assets[0].uri; // a local file:// URI, same shape as the camera result
}
}
Because both APIs return the same uri-shaped result, a screen that
accepts "a photo from anywhere" can offer both a "Take Photo" and "Choose from
Library" button feeding the same downstream code.
3. The File System Module
A photo's uri points into a cache the OS can clear at any time — to
keep it, copy it into the app's own document directory.
import * as FileSystem from 'expo-file-system';
async function saveToAppStorage(tempUri: string) {
const filename = tempUri.split('/').pop();
const destination = FileSystem.documentDirectory + filename;
await FileSystem.copyAsync({ from: tempUri, to: destination });
return destination; // safe to store this URI long-term
}
async function listSavedPhotos() {
const files = await FileSystem.readDirectoryAsync(FileSystem.documentDirectory!);
return files.filter((f) => f.endsWith('.jpg') || f.endsWith('.png'));
}
FileSystem.documentDirectory is sandboxed per app and persists across
launches (though not necessarily across an app reinstall) — it's the right home for
anything the user created that should survive the session that made it.
4. Uploading Media to a Backend
A local file URI is useless to a server — it has to travel as
multipart/form-data, the same format an HTML file input would send.
async function uploadPhoto(uri: string) {
const formData = new FormData();
formData.append('photo', {
uri,
name: 'photo.jpg',
type: 'image/jpeg',
} as any);
const response = await fetch('https://api.example.com/upload', {
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' },
body: formData,
});
if (!response.ok) throw new Error('Upload failed');
return response.json();
}
For large files or a progress bar, FileSystem.uploadAsync (a native
upload task with a progress callback) beats fetch — worth reaching for
once "did it work" isn't enough and users need to see it happening.
5. Hands-on Exercise
Build a photo journal screen
Capture or pick a photo, keep it permanently, and send it to a mock upload endpoint.
Requirements:
- A screen with two buttons: "Take Photo" (camera) and "Choose from Library" (image picker), both requesting the right permission first.
- After either action, copy the resulting file into
FileSystem.documentDirectoryand add it to an in-memory list. - Render the saved photos in a grid using their permanent URIs (not the original temp URIs).
- A "Upload" button per photo that sends it as
multipart/form-datato any mock endpoint (e.g. httpbin.org/post) and shows a success/failure state. - On next launch, re-read
FileSystem.documentDirectorywithreadDirectoryAsyncand repopulate the grid from disk.
If uploaded images arrive corrupted or zero-byte on the server, check the type field in the FormData part — it has to match the actual file (image/jpeg for a .jpg), and some backends also reject a missing or mismatched name extension.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is the URI returned by the camera or image picker not safe to store long-term as-is?
Why is the URI returned by the camera or image picker not safe to store long-term as-is?
That URI points into a temporary cache directory the OS is free to clear under storage pressure, independent of your app's own lifecycle. Copying the file into FileSystem.documentDirectory puts it under your app's own sandboxed, persistent storage instead.
Q2
Why can a "Choose from Library" and a "Take Photo" button both feed the same downstream save/upload code unchanged?
Why can a "Choose from Library" and a "Take Photo" button both feed the same downstream save/upload code unchanged?
expo-image-picker and expo-camera both resolve to the same shape — a local uri string — regardless of source. Code that operates on 'a photo URI' doesn't need to know or care whether it came from the camera or the library.
Q3
What's the advantage of FileSystem.uploadAsync over a plain fetch call with FormData for a large file?
What's the advantage of FileSystem.uploadAsync over a plain fetch call with FormData for a large file?
uploadAsync runs as a native upload task and can report progress via a callback, which a browser-style fetch call in React Native cannot. For small files either works, but a progress bar on a multi-megabyte photo or video needs the native task.
Q4
Why does useCameraPermissions returning null matter for what a camera screen renders on its first frame?
Why does useCameraPermissions returning null matter for what a camera screen renders on its first frame?
null means the permission status is still being read asynchronously — rendering the "permission denied" UI during that window would incorrectly flash it for users who already granted permission. Rendering nothing (or a spinner) until the hook resolves to a real granted/not-granted value avoids that flash.