Attach a Photo Without a Dead Button or a "No File" Error
You tap Add Photo on an enquiry form, and nothing happens at all. When you finally select an image and tap submit, your backend responds with an immediate 400 Bad Request error: "No file received".
Permission has four states, and the blocked one must offer a path to Settings. Let the platform write the multipart boundary: never set Content-Type for FormData yourself.
This is Day 10 of Ship Native, the finale of our three-part series on Forms and uploads. We began with Day 7: The Android Update That Broke My Form Layout to handle Android 15 keyboard insets. In Day 8: Why My Form Forgot the Input and Sent It Twice, we implemented field-level validation and button guards. Yesterday in Day 9: Why Save Kept Spinning, we added request timeouts. Today we solve the entire photo attachment lifecycle: from permission state machines to reliable multipart uploads.
The companion video features schematic diagrams, synthetic narration, and timed bilingual subtitles. The code examples below represent real production patterns tested across active production apps.
The symptom: a dead button and a missing file
Photo attachment problems in mobile apps almost always manifest in two frustrating failure modes:
- The dead button: The customer taps the camera or gallery button, and the app produces zero response. No system dialog appears, no error banner is displayed, and the form cannot be completed.
- The 400 Bad Request rejection: The customer successfully picks a photo, taps submit, but the server rejects the request claiming that the uploaded payload contains no file.
Both bugs stem from misunderstandings of mobile platform layers. The first is a mishandled permission state machine; the second is a broken HTTP multipart boundary.
The four permission states in React Native
Mobile permissions are not binary toggles. Modern mobile operating systems (iOS and Android) support four distinct permission states:
- Granted: The user approved access. The app can immediately open the photo library or camera.
- Denied: The permission has not yet been granted, but the app can still ask. Calling
request()presents the native system permission dialog. - Blocked: The user previously selected "Don't ask again" on Android, or tapped "Don't Allow" on iOS. The operating system permanently suppresses future dialogs. Calling
request()fails silently and returnsBLOCKEDimmediately. - Limited (iOS 14+): The user granted access to selected photos only, rather than their entire gallery. The app must treat this as a working state.
Here is the real production mistake we uncovered in our app Jeyad (CommentSection.tsx:361):
// Production bug from Jeyad:
if (!hasPermission) {
Alert.alert(
'Permission Required',
'Photo library permission is required to select photos',
[{ text: 'OK' }] // Dead end! No link to Settings.
);
return;
}
When a user blocks photo access, showing an alert with only an "OK" button leaves them stranded. Tapping the button again repeats the alert. The customer cannot fix the problem without manually leaving your app, opening their device Settings, searching for your app name, and locating the permission toggle.
The fix: checking, requesting, and linking to Settings
To recover from the blocked state, your application must explain why the permission is required and provide an explicit button that calls Linking.openSettings().
Here is the complete production permission machine from PermissionFlow.ts:
import { Platform, Alert, Linking } from 'react-native';
import {
check,
request,
PERMISSIONS,
RESULTS,
type Permission,
type PermissionStatus,
} from 'react-native-permissions';
/**
* Returns the correct photo library permission string for the current OS version.
* Android 13 (API 33+) introduced READ_MEDIA_IMAGES and deprecated READ_EXTERNAL_STORAGE.
*/
export const getPhotoPermission = (): Permission => {
if (Platform.OS === 'ios') {
return PERMISSIONS.IOS.PHOTO_LIBRARY;
}
const androidVersion = Number(Platform.Version);
if (androidVersion >= 33) {
return PERMISSIONS.ANDROID.READ_MEDIA_IMAGES;
}
return PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
};
export interface PermissionFlowResult {
canProceed: boolean;
status: PermissionStatus;
}
/**
* Robust photo library permission handler:
* 1. Checks current permission state.
* 2. If DENIED, triggers the native system permission dialog.
* 3. If BLOCKED (user selected "Don't ask again"), explains why and opens app Settings.
* 4. GRANTED and LIMITED (iOS 14+) proceed immediately.
*/
export const requestPhotoAccess = async (): Promise<PermissionFlowResult> => {
const permission = getPhotoPermission();
const currentStatus = await check(permission);
if (currentStatus === RESULTS.GRANTED || currentStatus === RESULTS.LIMITED) {
return { canProceed: true, status: currentStatus };
}
if (currentStatus === RESULTS.DENIED) {
const requestStatus = await request(permission);
if (requestStatus === RESULTS.GRANTED || requestStatus === RESULTS.LIMITED) {
return { canProceed: true, status: requestStatus };
}
if (requestStatus === RESULTS.BLOCKED) {
showSettingsPrompt();
}
return { canProceed: false, status: requestStatus };
}
if (currentStatus === RESULTS.BLOCKED) {
showSettingsPrompt();
return { canProceed: false, status: RESULTS.BLOCKED };
}
return { canProceed: false, status: currentStatus };
};
const showSettingsPrompt = () => {
Alert.alert(
'Photo Access Required',
'We need permission to access your gallery so you can attach a photo to your enquiry. Please enable Photos in your device settings.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Open Settings',
onPress: () => Linking.openSettings(),
},
]
);
};
Notice the control flow:
- If
currentStatusisDENIED, we callrequest(). If the user approves, they proceed immediately. - If
currentStatusis alreadyBLOCKED, we skiprequest()entirely and present the settings prompt. RESULTS.LIMITEDis treated as a successful state so iOS users who choose selected photos can still upload.
The Android 13 permission shift (API 33+)
In another production app, Gayar (ScanOrUploadScreen/index.js:207), our code requested PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE across all Android versions.
When Android 13 (API level 33) launched, Google deprecated READ_EXTERNAL_STORAGE for media access. On Android 13 devices, checking or requesting READ_EXTERNAL_STORAGE immediately returns BLOCKED or DENIED.
To support modern devices, you must branch permission strings based on Platform.Version:
- Android 13+ (API 33+): Request
PERMISSIONS.ANDROID.READ_MEDIA_IMAGES. - Android 12 and below (API 32 and lower): Request
PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE.
Here is the case study from PermissionMistakes.ts:
/**
* Real production mistakes re-opened from our codebase:
*
* MISTAKE 1: Calling deprecated READ_EXTERNAL_STORAGE on Android 13+
* Source: Gayar-NewApp/src/screens/TireSizeFlow/ScanOrUploadScreen/index.js:206-207
*
* if (Platform.OS === "android") {
* permissionResult = await check(PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE);
* // On Android 13+ (API 33+), this permission is deprecated and always returns DENIED/BLOCKED!
* // Modern Android requires: PERMISSIONS.ANDROID.READ_MEDIA_IMAGES
* }
*
* MISTAKE 2: Re-requesting a BLOCKED permission
* Source: Gayar-NewApp/src/screens/TireSizeFlow/ScanOrUploadScreen/index.js:210-213
*
* else if (permissionResult === RESULTS.DENIED || permissionResult === RESULTS.BLOCKED) {
* const result = await request(PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE);
* // If the user selected "Don't ask again", request() does NOT prompt the OS dialog!
* // It returns RESULTS.BLOCKED instantly without any user interaction.
* }
*
* MISTAKE 3: Denied alert with only an "OK" button (dead-end button)
* Source: Jeyad/src/components/PostItem/CommentSection.tsx:360-366
*
* if (!hasPermission) {
* Alert.alert(
* t('Permission Required'),
* t('Photo library permission is required to select photos'),
* [{ text: t('OK') }], // NO path to Settings! The button becomes permanently dead.
* );
* return;
* }
*
* THE RESOLUTION:
* 1. Branch permission strings by Number(Platform.Version) >= 33.
* 2. Separate RESULTS.DENIED (can request) from RESULTS.BLOCKED (must link to Settings).
* 3. Provide an actionable 'Open Settings' button using Linking.openSettings().
*/
export const MISTAKES_OVERVIEW = {
gayarStorageDeprecated: 'READ_EXTERNAL_STORAGE dead on Android 13+',
gayarBlockedReRequest: 'request() fails silently on BLOCKED permissions',
jeyadDeadEndAlert: 'Alert with only OK gives user no way to fix permission in Settings',
fixedResolution: 'Check API version, handle BLOCKED with Linking.openSettings()',
};
Multipart anatomy and the boundary delimiter
Once permission is granted and the user selects an image, you need to upload it. In HTTP, file uploads use multipart/form-data.
A multipart payload separates multiple fields (such as text values and binary files) using a unique boundary delimiter string. The HTTP header specifies this boundary:
POST /api/v1/attachments HTTP/1.1
Host: api.shipnative.dev
Content-Type: multipart/form-data; boundary=---------------------------974767299852498929531610575
Each part in the body starts with two dashes and that boundary string:
-----------------------------974767299852498929531610575
Content-Disposition: form-data; name="file"; filename="broken_tire.jpg"
Content-Type: image/jpeg
[Binary JPEG bytes...]
-----------------------------974767299852498929531610575--
When developers write file upload code, a common instinct is to explicitly set the header:
// BAD: Breaks multipart uploads in React Native!
headers: {
'Content-Type': 'multipart/form-data',
}
Setting Content-Type: multipart/form-data manually overwrites the entire header and strips the dynamic boundary parameter. When the raw payload reaches your backend server, multipart parsers (such as Multer, Busboy, or Django) cannot find the boundary delimiter. The server fails to parse the incoming stream and returns 400 Bad Request: No file received.
The two essential Axios settings (Annia)
In React Native, the native networking layer (RCTNetworking on iOS and OkHttp on Android) automatically constructs the multipart boundary when a FormData object is passed.
To let the native layer do its job, configure two settings in Axios, as proven in our app Annia (src/features/chat/api/index.ts:128-140):
import axios, { type AxiosInstance } from 'axios';
/**
* Production evidence from Annia: src/features/chat/api/index.ts:128-140
*
* Why these two settings are essential in React Native:
*
* 1. headers: { 'Content-Type': undefined }
* If you set 'Content-Type': 'multipart/form-data', Axios strips the boundary parameter.
* The raw HTTP header must look like:
* Content-Type: multipart/form-data; boundary=---------------------------974767299852498929531610575
* Setting it to undefined forces React Native's native networking layer (RCTNetworking)
* to generate the boundary string and write the full header automatically.
*
* 2. transformRequest: data => data
* Axios defaults to transforming object payloads into JSON strings.
* Passing an identity function preserves the raw FormData reference without serialization.
*/
export interface AttachmentFile {
uri: string;
type: string;
name: string;
}
export interface UploadResponse {
id: string;
url: string;
bytes: number;
}
export const uploadPhotoAttachment = async (
apiClient: AxiosInstance,
file: AttachmentFile,
onProgress?: (percent: number) => void
): Promise<UploadResponse> => {
const formData = new FormData();
// React Native FormData expects an object with uri, type, and name:
formData.append('file', {
uri: file.uri,
type: file.type || 'image/jpeg',
name: file.name || 'upload.jpg',
} as unknown as Blob);
const response = await apiClient.post<UploadResponse>('/api/v1/attachments', formData, {
// Setting Content-Type to undefined lets the platform write the boundary:
headers: {
'Content-Type': undefined,
},
// Prevent Axios from serializing FormData into JSON:
transformRequest: data => data,
onUploadProgress: progressEvent => {
if (progressEvent.total && onProgress) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percent);
}
},
});
return response.data;
};
The two rules:
headers: { 'Content-Type': undefined }: This instructs Axios to omit the Content-Type header so the underlying native platform can inject the header with the full, generated boundary string.transformRequest: data => data: By default, Axios attempts to serialize JavaScript objects into JSON strings. Providing an identity function ensures the rawFormDatainstance is passed directly to the native HTTP client without modification.
Retaining the selected image in state
Never discard the selected image URI until the server confirms a 200 or 201 response. If the network drops halfway through the upload, or if the server returns a temporary 503 error, keep the image preview and file object in form state.
This lets the customer tap Retry without forcing them to reopen their camera roll and select the file a second time.
Production checklist
Before shipping any photo attachment feature, verify these six criteria:
- Handle all 4 permission states: Check for granted, denied, blocked, and limited.
- Always offer a Settings action: When status is blocked, deep link via
Linking.openSettings(). - Support Android 13+: Use
READ_MEDIA_IMAGESon API level 33 and above. - Never hardcode Content-Type: Set
Content-Type: undefinedso the native layer writes the multipart boundary. - Prevent Axios serialization: Use
transformRequest: data => datafor FormData payloads. - Keep selected files in state: Ensure failed uploads can retry immediately.
Summary
This concludes Day 10 and the Forms and uploads module. Next up is Day 11, kicking off our State and offline module, focusing on persistent storage and local state architecture.
Comments