Photo uploads in web applications seem straightforward until deployed to real mobile devices with varied browsers, network conditions, and user behaviours. Initial implementations work perfectly in desktop Chrome during development, then fail mysteriously on Safari iOS, upload 0-byte files on Android, or lose progress when users rotate their devices mid-upload.
Week 41 transformed LetAdmin's inspection photo upload system through progressive refactoring: replacing manual DOM manipulation with Alpine.js reactive patterns, fixing mobile browser inconsistencies, implementing image compression, and ultimately achieving reliable uploads across devices and network conditions. This article explores how reactive patterns solve mobile upload challenges that imperative approaches struggle with.
What Your Team Will Notice
Photo uploads now work consistently across all mobile devices and browsers. Staff can add multiple photos during inspections using their phone's camera or selecting from the photo library, with uploads working identically on iPhone, Android, tablets, and desktop browsers.
The upload interface provides clear feedback at every stage: "Compressing image..." whilst reducing file size, "Uploading..." with a progress spinner during transfer, "Upload complete" with visual confirmation. If uploads fail due to network issues, clear error messages explain what happened with retry options.
Photos appear immediately in the inspection gallery as thumbnails, even before server upload completes. This optimistic UI prevents the frustrating "did my upload work?" uncertainty common in traditional file uploads. When rotating devices mid-upload, progress persists—the interface adapts to the new orientation without losing state.
For inspections conducted offline, photos queue locally with "pending upload" indicators. Once connectivity returns, automatic sync uploads queued photos transparently in the background whilst staff continue working.
The Problem: Manual DOM Manipulation on Mobile
Initial photo upload implementation used jQuery-style DOM manipulation:
// Initial approach - manual DOM manipulation
class PhotoUploader {
constructor(containerSelector) {
this.container = document.querySelector(containerSelector);
this.photos = [];
}
addPhoto(file) {
this.photos.push(file);
// Manually create DOM elements
const photoElement = document.createElement('div');
photoElement.className = 'photo-preview';
photoElement.innerHTML = `
<img src="${URL.createObjectURL(file)}" />
<button class="remove-photo">Remove</button>
<span class="upload-status">Uploading...</span>
`;
// Manually attach event listeners
photoElement.querySelector('.remove-photo').addEventListener('click', () => {
this.removePhoto(photoElement, file);
});
// Manually insert into DOM
this.container.appendChild(photoElement);
// Upload photo
this.uploadPhoto(file, photoElement);
}
removePhoto(element, file) {
// Manually remove from array
const index = this.photos.indexOf(file);
if (index > -1) {
this.photos.splice(index, 1);
}
// Manually remove from DOM
element.remove();
}
uploadPhoto(file, element) {
const formData = new FormData();
formData.append('photo', file);
fetch('/api/upload', { method: 'POST', body: formData })
.then(response => response.json())
.then(data => {
// Manually update DOM
const statusSpan = element.querySelector('.upload-status');
statusSpan.textContent = 'Uploaded';
statusSpan.className = 'upload-status success';
})
.catch(error => {
// Manually update DOM
const statusSpan = element.querySelector('.upload-status');
statusSpan.textContent = 'Failed';
statusSpan.className = 'upload-status error';
});
}
}
This approach created several problems on mobile browsers:
State Synchronization: The photos array and DOM got out of sync when mobile browsers suspended background tabs, causing ghost photos or duplicate uploads.
Memory Management: URL.createObjectURL() without proper revocation caused memory leaks during long inspection sessions with many photos.
Event Listener Leaks: Manually attached listeners weren't properly cleaned up when photos were removed, accumulating listeners over time.
Device Rotation: Reconstructing the interface after orientation changes required re-querying and re-attaching listeners, often failing to restore complete state.
The Solution: Alpine.js Reactive Patterns
Alpine.js provides reactive state management similar to Vue.js but with minimal overhead:
// Refactored approach - Alpine.js reactive patterns
<div x-data="photoUploader()">
<!-- File input -->
<input type="file"
accept="image/*"
multiple
@change="handleFileSelection($event)"
class="hidden"
x-ref="fileInput" />
<button @click="$refs.fileInput.click()" class="btn btn-primary">
Add Photos
</button>
<!-- Photo gallery - automatically reactive -->
<div class="photo-gallery">
<template x-for="(photo, index) in photos" :key="photo.id">
<div class="photo-preview">
<!-- Image preview -->
<img :src="photo.previewUrl"
:alt="`Photo ${index + 1}`"
class="photo-thumbnail" />
<!-- Upload status -->
<div class="upload-status" :class="photo.status">
<span x-show="photo.status === 'compressing'">
Compressing...
</span>
<span x-show="photo.status === 'uploading'">
Uploading...
</span>
<span x-show="photo.status === 'uploaded'">
✓ Uploaded
</span>
<span x-show="photo.status === 'failed'">
✗ Failed
</span>
</div>
<!-- Remove button -->
<button @click="removePhoto(index)"
type="button"
class="btn-remove">
Remove
</button>
</div>
</template>
</div>
</div>
<script>
function photoUploader() {
return {
photos: [],
nextId: 1,
async handleFileSelection(event) {
const files = Array.from(event.target.files);
for (const file of files) {
await this.addPhoto(file);
}
// Clear input to allow re-selecting same file
event.target.value = '';
},
async addPhoto(file) {
// Create preview URL
const previewUrl = URL.createObjectURL(file);
// Add to reactive array with status tracking
const photo = {
id: this.nextId++,
file: file,
previewUrl: previewUrl,
status: 'compressing',
serverId: null
};
this.photos.push(photo);
// Process and upload
await this.processPhoto(photo);
},
async processPhoto(photo) {
try {
// Compress image for mobile uploads
const compressedFile = await this.compressImage(photo.file);
photo.status = 'uploading';
// Upload to server
const formData = new FormData();
formData.append('photo[image]', compressedFile);
formData.append('photo[inspection_id]', this.inspectionId);
const response = await fetch('/inspections/add_photo', {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: formData
});
if (response.ok) {
const data = await response.json();
photo.serverId = data.id;
photo.status = 'uploaded';
} else {
throw new Error('Upload failed');
}
} catch (error) {
console.error('Photo processing failed:', error);
photo.status = 'failed';
}
},
async compressImage(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate dimensions maintaining aspect ratio
const maxDimension = 1920;
let width = img.width;
let height = img.height;
if (width > height && width > maxDimension) {
height *= maxDimension / width;
width = maxDimension;
} else if (height > maxDimension) {
width *= maxDimension / height;
height = maxDimension;
}
canvas.width = width;
canvas.height = height;
// Draw and compress
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
resolve(new File([blob], file.name, {
type: 'image/jpeg',
lastModified: Date.now()
}));
}, 'image/jpeg', 0.85); // 85% quality
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
},
removePhoto(index) {
const photo = this.photos[index];
// Revoke preview URL to prevent memory leaks
URL.revokeObjectURL(photo.previewUrl);
// Remove from array (triggers reactive update)
this.photos.splice(index, 1);
// If already uploaded to server, delete
if (photo.serverId) {
fetch(`/photos/${photo.serverId}`, {
method: 'DELETE',
headers: {
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
}
});
}
},
// Cleanup on component destroy
destroy() {
this.photos.forEach(photo => {
URL.revokeObjectURL(photo.previewUrl);
});
}
};
}
</script>
This reactive approach solves the previous problems:
Automatic DOM Sync: Alpine automatically updates the DOM when photos array changes—no manual synchronisation needed.
Single Source of Truth: The photos array is the definitive state; the DOM is just a projection of that state.
Memory Safety: Proper cleanup in removePhoto() and destroy() prevents preview URL leaks.
Rotation Resilience: When browsers reconstruct the component after rotation, Alpine rebuilds the DOM from the photos array automatically.
Image Compression for Mobile Networks
Mobile uploads often fail due to large file sizes on slow connections. Client-side compression reduces bandwidth requirements:
async compressImage(file) {
// Load original image
const img = await this.loadImage(file);
// Create canvas for resizing
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate target dimensions (max 1920px)
const maxDimension = 1920;
let { width, height } = this.calculateDimensions(
img.width,
img.height,
maxDimension
);
canvas.width = width;
canvas.height = height;
// Draw resized image
ctx.drawImage(img, 0, 0, width, height);
// Convert to compressed JPEG
return new Promise((resolve) => {
canvas.toBlob(
(blob) => {
resolve(new File([blob], file.name, {
type: 'image/jpeg',
lastModified: Date.now()
}));
},
'image/jpeg',
0.85 // 85% quality - good balance of size vs quality
);
});
}
calculateDimensions(width, height, maxDimension) {
if (width > height && width > maxDimension) {
return {
width: maxDimension,
height: Math.round(height * (maxDimension / width))
};
} else if (height > maxDimension) {
return {
width: Math.round(width * (maxDimension / height)),
height: maxDimension
};
}
return { width, height };
}
Compression typically reduces files by 60-80% whilst maintaining good visual quality for inspection documentation.
Testing Reactive Photo Uploads
Testing reactive components requires simulating user interactions:
describe('PhotoUploader', () => {
let uploader;
beforeEach(() => {
document.body.innerHTML = `<div x-data="photoUploader()"></div>`;
uploader = Alpine.$data(document.querySelector('[x-data]'));
});
it('adds photos to reactive array', async () => {
const file = new File(['photo'], 'test.jpg', { type: 'image/jpeg' });
await uploader.addPhoto(file);
expect(uploader.photos.length).toBe(1);
expect(uploader.photos[0].file).toBe(file);
expect(uploader.photos[0].status).toBe('compressing');
});
it('removes photos and cleans up URLs', () => {
uploader.photos = [
{ id: 1, previewUrl: 'blob:test', status: 'uploaded' }
];
uploader.removePhoto(0);
expect(uploader.photos.length).toBe(0);
// URL.revokeObjectURL should be called (requires spy)
});
it('compresses large images', async () => {
const largeFile = new File(['x'.repeat(1000000)], 'large.jpg', {
type: 'image/jpeg'
});
const compressed = await uploader.compressImage(largeFile);
expect(compressed.size).toBeLessThan(largeFile.size);
});
});
These tests verify reactive behaviour and compression functionality.
What's Next
The reactive photo upload foundation enables sophisticated features: batch upload optimization uploading multiple photos concurrently, smart retry logic with exponential backoff for failed uploads, photo reordering through drag-and-drop, and thumbnail generation with server-side processing for consistent display across devices.
Future enhancements might include progressive image loading showing low-quality previews immediately with high-quality replacements loaded later, photo editing capabilities allowing rotation and cropping before upload, and camera permissions handling providing clear guidance when permissions are denied.
By refactoring from manual DOM manipulation to Alpine.js reactive patterns, LetAdmin achieved reliable mobile photo uploads that work consistently across devices, browsers, and network conditions—transforming photo documentation from a frustrating bottleneck into a seamless workflow component.
