Picking an Image
Use expo-image-picker to open the system photo library or camera, read the result shape, and shrink the file before upload.
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 0.7 })expo-image-picker opens the operating system's own photo picker, or the system camera UI, and hands back what the user chose. You do not draw the grid of thumbnails; the OS does. That is also why the library picker needs no permission prompt on recent iOS versions: the browsing happens outside your app.
The result shape
Both launchImageLibraryAsync(options) and launchCameraAsync(options) resolve to the same object: { canceled, assets }. Check canceled first. When it is true, assets is null. Otherwise assets is an array with one entry per selected item, each carrying uri, width, height, fileName, fileSize and type ('image', 'video' or 'livePhoto').
The uri is a local file path on the device, not a web URL. Pass it to Image as source={{ uri }}.
Choosing what can be picked
mediaTypes takes the strings 'images', 'videos' or 'livePhotos'. A single value and a one-element array are the same thing: mediaTypes: 'images' equals mediaTypes: ['images']. These strings replaced the deprecated MediaTypeOptions enum, so MediaTypeOptions.Images in older code means ['images'].
Shrink on the way out
Set quality between 0 and 1 (it only affects images). A phone camera file is several megabytes; quality: 0.7 cuts most of that before the bytes ever reach your upload code. Resizing pixel dimensions is a separate job, covered in the resizing lesson.
Example
import { useState } from "react";
import * as ImagePicker from "expo-image-picker";
import { Button, Image, StyleSheet, Text, View } from "react-native";
export default function PickAvatar() {
const [uri, setUri] = useState<string | null>(null);
const [bytes, setBytes] = useState<number | null>(null);
async function pickFromLibrary() {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"], // same as mediaTypes: "images"
allowsEditing: true,
quality: 0.7, // re-encodes the JPEG instead of uploading the original
});
if (result.canceled) return; // assets is null when canceled is true
const asset = result.assets[0];
setUri(asset.uri);
setBytes(asset.fileSize ?? null);
}
return (
<View style={styles.screen}>
{/* source={{ uri }} is shorthand for source={{ uri: uri }} */}
{uri ? <Image source={{ uri }} style={styles.preview} /> : null}
<Button title="Choose photo" onPress={pickFromLibrary} />
<Text>{bytes === null ? "No image yet" : `${Math.round(bytes / 1024)} kB`}</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
preview: { width: 160, height: 160, borderRadius: 80 },
});When to use it
- Letting a user set a profile picture from their photo library.
- Attaching up to five photos to a maintenance ticket.
- Capturing a receipt photo during an expense entry flow.
- Reducing a multi-megabyte camera file before it hits a mobile data connection.
More examples
Selecting several images at once
Multiple selection returns every chosen item in assets, so the screen maps the array to uris and shows them in a three-column grid. FlatList rather than ScrollView, because the user can keep adding. allowsMultipleSelection and allowsEditing are not supported together: with multi-select on, there is no crop step, so you crop afterwards in code. Reach for this on anything that attaches more than one file, such as a post composer or a damage report.
import { useState } from "react";
import * as ImagePicker from "expo-image-picker";
import { Button, FlatList, Image, StyleSheet, View } from "react-native";
export default function PickMany() {
const [uris, setUris] = useState<string[]>([]);
async function pick() {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images", "videos"],
allowsMultipleSelection: true,
selectionLimit: 5, // 0 means no limit
});
if (result.canceled) return;
// Shorthand arrow body; longhand: result.assets.map(function (a) { return a.uri; })
setUris(result.assets.map((a) => a.uri));
}
return (
<View style={styles.screen}>
<Button title="Add photos" onPress={pick} />
<FlatList
data={uris}
keyExtractor={(uri) => uri}
numColumns={3}
renderItem={({ item }) => <Image source={{ uri: item }} style={styles.cell} />}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, paddingTop: 32, gap: 12 },
cell: { width: 104, height: 104, margin: 2 },
});Taking a photo without building a camera screen
launchCameraAsync hands the capture to the system camera app, which is enough whenever you need one photo and no custom preview. Unlike the library picker, this does need the camera permission, so the component holds the permission state from useCameraPermissions and shows a Settings hint once canAskAgain is false. exif only controls whether the EXIF block is returned on the asset, so leaving it false keeps that metadata out of your JavaScript, not out of the captured file; strip or re-encode the image yourself if location data must never leave the device.
import * as ImagePicker from "expo-image-picker";
import { Button, StyleSheet, Text, View } from "react-native";
type ShotButtonProps = { onShot: (uri: string) => void };
export default function ShotButton({ onShot }: ShotButtonProps) {
const [permission, requestPermission] = ImagePicker.useCameraPermissions();
async function shoot() {
if (!permission?.granted) {
const asked = await requestPermission();
if (!asked.granted) return; // the user declined; do not ask again in a loop
}
const result = await ImagePicker.launchCameraAsync({ quality: 0.6, exif: false });
if (!result.canceled) onShot(result.assets[0].uri);
}
return (
<View style={styles.box}>
<Button title="Take photo" onPress={shoot} />
{permission && !permission.canAskAgain ? (
<Text>Camera access is off. Turn it on in Settings to use this.</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({ box: { gap: 8, padding: 16 } });Posting a picked file to a server
The asset uri is a local file path, so it cannot be sent as JSON. This is the multipart form that every React Native upload ends up using, and the { uri, name, type } object is the platform-specific part: the native networking layer reads the file from disk itself. Setting Content-Type by hand breaks it, because your header has no boundary parameter. Call this with the uri from the picker after quality has already reduced the file.
export type UploadResult = { id: string; url: string };
export async function uploadImage(
uri: string,
fileName: string | null
): Promise<UploadResult> {
const form = new FormData();
// React Native accepts this { uri, name, type } shape where a browser wants a Blob.
form.append("file", {
uri,
name: fileName ?? "upload.jpg",
type: "image/jpeg",
} as unknown as Blob);
const response = await fetch("https://api.example.com/images", {
method: "POST",
body: form, // do not set Content-Type; fetch adds the multipart boundary
});
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
return (await response.json()) as UploadResult;
}
Discussion