Before sending a file to the server, React can display an instant preview using the browser’s URL.createObjectURL() API — it creates a temporary URL that points directly to the in-memory file, with no network request and no server involved. Combined with file type and size validation in the browser, this gives users immediate feedback about their selection without wasting bandwidth. The preview URL must be released with URL.revokeObjectURL() when the component unmounts, or it leaks the file’s memory for the lifetime of the tab.
File Input with Instant Preview
import { useState, useEffect, useRef } from "react";
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
function ImagePicker({ value, onChange, label = "Choose image" }) {
const [preview, setPreview] = useState(value ?? null);
const [error, setError] = useState(null);
const inputRef = useRef(null);
// Revoke previous object URL when preview changes, to free memory
useEffect(() => {
return () => {
if (preview && preview.startsWith("blob:")) {
URL.revokeObjectURL(preview);
}
};
}, [preview]);
function handleFileChange(e) {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
// ── Client-side validation ────────────────────────────────────────────
if (!ACCEPTED_TYPES.includes(file.type)) {
setError("Please select a JPEG, PNG, or WebP image");
return;
}
if (file.size > MAX_SIZE_BYTES) {
setError(`Image must be under ${MAX_SIZE_BYTES / 1024 / 1024} MB`);
return;
}
// ── Create instant preview URL ────────────────────────────────────────
const objectUrl = URL.createObjectURL(file);
setPreview(objectUrl);
// Pass the File object up to the parent for upload
onChange(file);
}
function handleClear() {
if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview);
setPreview(null);
onChange(null);
if (inputRef.current) inputRef.current.value = ""; // reset input
}
return (
<div className="space-y-2">
{/* Preview */}
{preview && (
<div className="relative w-32 h-32">
<img src={preview} alt="Preview"
className="w-full h-full object-cover rounded-lg border" />
<button
type="button"
onClick={handleClear}
className="absolute -top-2 -right-2 bg-red-500 text-white
w-5 h-5 rounded-full text-xs flex items-center justify-center"
aria-label="Remove image"
>
×
</button>
</div>
)}
{/* File input — hidden, triggered by button */}
<input
ref={inputRef}
type="file"
accept={ACCEPTED_TYPES.join(",")}
onChange={handleFileChange}
className="sr-only"
id="image-picker"
/>
<label
htmlFor="image-picker"
className="inline-flex items-center gap-2 px-4 py-2 border rounded-lg
cursor-pointer hover:bg-gray-50 text-sm font-medium"
>
📎 {label}
</label>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
);
}
Note:
URL.createObjectURL(file) creates a reference to the file in the browser’s memory — the URL looks like blob:http://localhost:5173/abc-123. It does not upload anything; it is a local pointer. The browser keeps the file data in memory as long as the object URL exists. Call URL.revokeObjectURL(url) when you no longer need the preview to release the memory. Always revoke in a useEffect cleanup or when the user clears the selection.Tip: Hide the native file input (
className="sr-only" in Tailwind, or display: none) and trigger it via a styled <label htmlFor="file-input-id"> or a button that calls inputRef.current.click(). The native file input is notoriously difficult to style consistently across browsers and operating systems. A hidden input with a custom trigger gives full styling control while keeping the actual file selection behaviour intact.Warning: Client-side validation (file type, file size) is a UX convenience — it provides fast feedback without a network round-trip. It is not a security measure. The browser’s
file.type is the MIME type the client declares, not a cryptographic guarantee. A malicious user can easily forge the MIME type. The FastAPI backend must independently validate every uploaded file using magic byte detection (Pillow’s Image.open() verifying it is actually an image) and size limits, as covered in Chapter 30.Upload Progress
import { useState } from "react";
import axios from "axios";
function useUploadWithProgress() {
const [progress, setProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState(null);
async function upload(file, endpoint) {
const formData = new FormData();
formData.append("file", file);
setIsUploading(true);
setProgress(0);
setError(null);
try {
const response = await axios.post(endpoint, formData, {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
setProgress(
Math.round((progressEvent.loaded * 100) / progressEvent.total)
);
}
},
});
return response.data;
} catch (err) {
setError(err.response?.data?.detail ?? "Upload failed");
throw err;
} finally {
setIsUploading(false);
}
}
return { upload, progress, isUploading, error };
}
// Progress bar component
function UploadProgress({ progress, isUploading }) {
if (!isUploading) return null;
return (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-150"
style={{ width: `${progress}%` }}
/>
<p className="text-xs text-gray-500 mt-1">{progress}% uploaded</p>
</div>
);
}
Common Mistakes
Mistake 1 — Not revoking object URLs (memory leak)
❌ Wrong — preview URLs accumulate in memory:
const url = URL.createObjectURL(file);
setPreview(url); // no cleanup — memory is never freed!
✅ Correct — revoke in useEffect cleanup and on clear.
Mistake 2 — Not resetting the input value after clear
❌ Wrong — selecting the same file again after clearing does not trigger onChange:
function handleClear() {
setPreview(null);
// input.value is still set — re-selecting same file triggers no onChange!
✅ Correct:
if (inputRef.current) inputRef.current.value = ""; // ✓ resets file input
Quick Reference
| Task | Code |
|---|---|
| Instant preview | URL.createObjectURL(file) |
| Release memory | URL.revokeObjectURL(url) in cleanup |
| Style file input | Hide with sr-only, trigger via label or .click() |
| Validate type | ACCEPTED_TYPES.includes(file.type) |
| Validate size | file.size > MAX_SIZE_BYTES |
| Upload progress | Axios onUploadProgress callback |