Frontend API Layer
Project Structure
The frontend is built with React 19 and TypeScript, organized into feature-based folders:
src/
├── api/
│ └── photos-api.ts # All network calls
├── features/
│ ├── photos/ # Photo display components
│ └── upload/
│ ├── SimpleUploadForm.tsx
│ └── UploadForm.tsx
└── App.tsx # Routing
Keeping all API calls in one file means components never call fetch directly — they only deal with typed results.
The Result Pattern
Rather than throwing exceptions on failure, every API function returns an ApiResult<T> — a discriminated union that forces callers to handle both outcomes:
frontend/src/api/photos-api.ts#L12-L18 on GitHub
// src/api/photos-api.ts
export type ApiResult<T> = {
success: true;
data: T;
} | {
success: false;
error: string;
};
TypeScript narrows the type automatically when you check result.success:
const result = await uploadPhoto(formData);
if (result.success) {
console.log(result.data); // TypeScript knows this is PhotoResponse
} else {
console.log(result.error); // TypeScript knows this is string
}
Accessing result.data without checking result.success first is a type error — TypeScript enforces correct error handling at compile time.
Response Types
frontend/src/api/photos-api.ts#L4-L9 on GitHub
export type PhotoResponse = {
id: number;
photo_url: string;
title?: string;
description?: string;
};
This mirrors the PhotoResponse Pydantic model on the backend.
API Functions
frontend/src/api/photos-api.ts#L23-L86 on GitHub
const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
export async function getAllPhotos(): Promise<ApiResult<PhotoResponse[]>> {
try {
const response = await fetch(`${baseUrl}/api/photos`);
if (!response.ok) {
return { success: false, error: "Couldn't fetch photos" };
}
const photos = await response.json();
return { success: true, data: photos };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error occurred"
};
}
}
export async function uploadPhoto(formData: FormData): Promise<ApiResult<PhotoResponse>> {
try {
const response = await fetch(`${baseUrl}/api/photos`, {
method: "POST",
body: formData,
});
if (!response.ok) {
return { success: false, error: "Unable to upload image" };
}
const photo = await response.json();
return { success: true, data: photo };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error occurred"
};
}
}
Why this approach?
- No
try/catchin components — error handling is centralised here, not scattered across the codebase - Consistent error shape — every failure produces
{ success: false, error: string }, whether it’s a network error, a400from the server, or anything else - Easy to test — functions return plain objects, no exception mocking required
- Type-safe at the call site — TypeScript won’t let you use
result.datawithout first confirmingresult.success === true
Uploading with FormData
The uploadPhoto function passes FormData directly as the request body. The browser sets the correct Content-Type: multipart/form-data header automatically — you should not set it manually, as doing so omits the boundary string that the server needs to parse the body.