Week 7: Camera, Media & the File System

This week is where the app stops being confined to a rectangle of JavaScript and starts touching the device — a real camera feed, the photo library, and a file system that outlives any single screen. Three Expo modules cover almost every media need an app has, from taking a photo to shipping it to a server.

Phase 4 of 8 Week 7 of 22 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Request camera permission and capture a photo with expo-camera
  • Let a user pick an existing photo or video with expo-image-picker
  • Read, write and cache files with Expo's FileSystem module
  • Upload a captured file to a backend as multipart form data

1. Capturing Photos with expo-camera

terminal
npx expo install expo-camera
screens/CameraScreen.tsx
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.

terminal
npx expo install expo-image-picker
picking a photo from the library
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.

saving a captured photo permanently
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.

uploading a photo
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

Hands-on

Build a photo journal screen

Capture or pick a photo, keep it permanently, and send it to a mock upload endpoint.

Requirements:

  1. A screen with two buttons: "Take Photo" (camera) and "Choose from Library" (image picker), both requesting the right permission first.
  2. After either action, copy the resulting file into FileSystem.documentDirectory and add it to an in-memory list.
  3. Render the saved photos in a grid using their permanent URIs (not the original temp URIs).
  4. A "Upload" button per photo that sends it as multipart/form-data to any mock endpoint (e.g. httpbin.org/post) and shows a success/failure state.
  5. On next launch, re-read FileSystem.documentDirectory with readDirectoryAsync and repopulate the grid from disk.
Hint

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?

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?

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?

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?

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.